diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000..dcf3465
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,8 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(dotnet build:*)",
+ "Bash(dotnet test:*)"
+ ]
+ }
+}
diff --git a/AyCode.Core.Tests/Serialization/AcBinarySerializerCircularReferenceTests.cs b/AyCode.Core.Tests/Serialization/AcBinarySerializerCircularReferenceTests.cs
index c58bc74..9aa9a19 100644
--- a/AyCode.Core.Tests/Serialization/AcBinarySerializerCircularReferenceTests.cs
+++ b/AyCode.Core.Tests/Serialization/AcBinarySerializerCircularReferenceTests.cs
@@ -1,5 +1,5 @@
using AyCode.Core.Extensions;
-using static AyCode.Core.Tests.Serialization.AcSerializerModels;
+using static AyCode.Core.Tests.TestModels.AcSerializerModels;
namespace AyCode.Core.Tests.Serialization;
diff --git a/AyCode.Core.Tests/Serialization/AcBinarySerializerDateTimeTests.cs b/AyCode.Core.Tests/Serialization/AcBinarySerializerDateTimeTests.cs
index 8cc848c..fd5e52e 100644
--- a/AyCode.Core.Tests/Serialization/AcBinarySerializerDateTimeTests.cs
+++ b/AyCode.Core.Tests/Serialization/AcBinarySerializerDateTimeTests.cs
@@ -1,5 +1,5 @@
using AyCode.Core.Extensions;
-using static AyCode.Core.Tests.Serialization.AcSerializerModels;
+using static AyCode.Core.Tests.TestModels.AcSerializerModels;
using static AyCode.Core.Tests.Serialization.AcSerializerTestHelper;
namespace AyCode.Core.Tests.Serialization;
diff --git a/AyCode.Core.Tests/Serialization/AcBinarySerializerDiagnosticTests.cs b/AyCode.Core.Tests/Serialization/AcBinarySerializerDiagnosticTests.cs
index dd53541..356f8fb 100644
--- a/AyCode.Core.Tests/Serialization/AcBinarySerializerDiagnosticTests.cs
+++ b/AyCode.Core.Tests/Serialization/AcBinarySerializerDiagnosticTests.cs
@@ -1,7 +1,7 @@
using AyCode.Core.Extensions;
using AyCode.Core.Serializers.Binaries;
using System.Reflection;
-using static AyCode.Core.Tests.Serialization.AcSerializerModels;
+using static AyCode.Core.Tests.TestModels.AcSerializerModels;
namespace AyCode.Core.Tests.Serialization;
diff --git a/AyCode.Core.Tests/Serialization/AcBinarySerializerGenericTypeTests.cs b/AyCode.Core.Tests/Serialization/AcBinarySerializerGenericTypeTests.cs
index b913756..d240793 100644
--- a/AyCode.Core.Tests/Serialization/AcBinarySerializerGenericTypeTests.cs
+++ b/AyCode.Core.Tests/Serialization/AcBinarySerializerGenericTypeTests.cs
@@ -1,5 +1,5 @@
using AyCode.Core.Extensions;
-using static AyCode.Core.Tests.Serialization.AcSerializerModels;
+using static AyCode.Core.Tests.TestModels.AcSerializerModels;
namespace AyCode.Core.Tests.Serialization;
diff --git a/AyCode.Core.Tests/Serialization/AcBinarySerializerNavigationPropertyTests.cs b/AyCode.Core.Tests/Serialization/AcBinarySerializerNavigationPropertyTests.cs
index 71154ea..40ea031 100644
--- a/AyCode.Core.Tests/Serialization/AcBinarySerializerNavigationPropertyTests.cs
+++ b/AyCode.Core.Tests/Serialization/AcBinarySerializerNavigationPropertyTests.cs
@@ -1,5 +1,5 @@
using AyCode.Core.Extensions;
-using static AyCode.Core.Tests.Serialization.AcSerializerModels;
+using static AyCode.Core.Tests.TestModels.AcSerializerModels;
namespace AyCode.Core.Tests.Serialization;
diff --git a/AyCode.Core.Tests/Serialization/AcBinarySerializerNullableTests.cs b/AyCode.Core.Tests/Serialization/AcBinarySerializerNullableTests.cs
index 0854d24..3a0865c 100644
--- a/AyCode.Core.Tests/Serialization/AcBinarySerializerNullableTests.cs
+++ b/AyCode.Core.Tests/Serialization/AcBinarySerializerNullableTests.cs
@@ -1,5 +1,5 @@
using AyCode.Core.Extensions;
-using static AyCode.Core.Tests.Serialization.AcSerializerModels;
+using static AyCode.Core.Tests.TestModels.AcSerializerModels;
using static AyCode.Core.Tests.Serialization.AcSerializerTestHelper;
namespace AyCode.Core.Tests.Serialization;
diff --git a/AyCode.Core.Tests/Serialization/AcBinarySerializerObjectTests.cs b/AyCode.Core.Tests/Serialization/AcBinarySerializerObjectTests.cs
index 4e48ec7..f5977ec 100644
--- a/AyCode.Core.Tests/Serialization/AcBinarySerializerObjectTests.cs
+++ b/AyCode.Core.Tests/Serialization/AcBinarySerializerObjectTests.cs
@@ -1,5 +1,5 @@
using AyCode.Core.Extensions;
-using static AyCode.Core.Tests.Serialization.AcSerializerModels;
+using static AyCode.Core.Tests.TestModels.AcSerializerModels;
namespace AyCode.Core.Tests.Serialization;
diff --git a/AyCode.Core.Tests/Serialization/AcBinarySerializerStringInterningTests.cs b/AyCode.Core.Tests/Serialization/AcBinarySerializerStringInterningTests.cs
index 9c8bbca..d71d16b 100644
--- a/AyCode.Core.Tests/Serialization/AcBinarySerializerStringInterningTests.cs
+++ b/AyCode.Core.Tests/Serialization/AcBinarySerializerStringInterningTests.cs
@@ -1,6 +1,6 @@
using AyCode.Core.Extensions;
using AyCode.Core.Serializers.Binaries;
-using static AyCode.Core.Tests.Serialization.AcSerializerModels;
+using static AyCode.Core.Tests.TestModels.AcSerializerModels;
using static AyCode.Core.Tests.Serialization.AcSerializerTestHelper;
namespace AyCode.Core.Tests.Serialization;
diff --git a/AyCode.Core.Tests/Serialization/AcExpressionNodeSerializationTests.cs b/AyCode.Core.Tests/Serialization/AcExpressionNodeSerializationTests.cs
new file mode 100644
index 0000000..794269e
--- /dev/null
+++ b/AyCode.Core.Tests/Serialization/AcExpressionNodeSerializationTests.cs
@@ -0,0 +1,317 @@
+using AyCode.Core.Serializers.Binaries;
+using AyCode.Core.Serializers.Expressions;
+using AyCode.Core.Serializers.Jsons;
+using AyCode.Core.Tests.TestModels;
+
+namespace AyCode.Core.Tests.Serialization;
+
+///
+/// Tests for AcExpressionNode serialization.
+/// These tests verify that the serializers properly handle expression nodes
+/// with the new type-safe constant value storage.
+///
+[TestClass]
+public class AcExpressionNodeSerializationTests
+{
+ #region JSON Serializer Tests
+
+ ///
+ /// Tests that AcExpressionNode with type-safe properties can be JSON serialized.
+ ///
+ [TestMethod]
+ public void AcJsonSerializer_WithAcExpressionNode_RoundTrip_Works()
+ {
+ // Arrange - Create an expression with a constant value
+ System.Linq.Expressions.Expression> filterExpression =
+ item => item.Quantity > 5;
+
+ var expressionNode = AcExpressionConverter.ToNode(filterExpression);
+
+ // Act - Serialize
+ var json = AcJsonSerializer.Serialize(expressionNode);
+ Console.WriteLine($"Serialized JSON: {json}");
+
+ Assert.IsNotNull(json, "JSON should not be null");
+ Assert.IsTrue(json.Length > 0, "JSON should not be empty");
+
+ // Deserialize
+ var deserialized = AcJsonDeserializer.Deserialize(json);
+ Assert.IsNotNull(deserialized, "Deserialized node should not be null");
+
+ // Rebuild and test
+ var rebuiltExpression = AcExpressionRebuilder.FromNode(deserialized);
+ var compiled = rebuiltExpression.Compile();
+
+ var matchingItem = new TestOrderItem { Id = 1, Quantity = 10 };
+ var nonMatchingItem = new TestOrderItem { Id = 2, Quantity = 3 };
+
+ Assert.IsTrue(compiled(matchingItem), "Matching item should pass filter");
+ Assert.IsFalse(compiled(nonMatchingItem), "Non-matching item should fail filter");
+ }
+
+ ///
+ /// Tests that decimal constant values are preserved after JSON round-trip.
+ ///
+ [TestMethod]
+ public void AcJsonSerializer_WithDecimalValue_PreservesType()
+ {
+ // Arrange - Node with decimal constant
+ var node = new AcExpressionNode
+ {
+ NodeType = System.Linq.Expressions.ExpressionType.Constant,
+ TypeName = "System.Decimal"
+ };
+ node.SetValue(99.99m);
+
+ // Act - JSON round-trip
+ var json = AcJsonSerializer.Serialize(node);
+ Console.WriteLine($"JSON: {json}");
+
+ var deserialized = AcJsonDeserializer.Deserialize(json);
+
+ // Assert
+ Assert.IsNotNull(deserialized);
+ Assert.AreEqual(ConstantValueType.Decimal, deserialized.ValueType);
+
+ var value = deserialized.GetValue();
+ Assert.IsNotNull(value, "Value should not be null");
+ Assert.AreEqual(typeof(decimal), value.GetType(), "Should preserve decimal type");
+ Assert.AreEqual(99.99m, (decimal)value);
+ }
+
+ ///
+ /// Tests that Guid constant values are preserved after JSON round-trip.
+ ///
+ [TestMethod]
+ public void AcJsonSerializer_WithGuidValue_PreservesType()
+ {
+ // Arrange
+ var testGuid = Guid.NewGuid();
+ var node = new AcExpressionNode
+ {
+ NodeType = System.Linq.Expressions.ExpressionType.Constant,
+ TypeName = "System.Guid"
+ };
+ node.SetValue(testGuid);
+
+ // Act
+ var json = AcJsonSerializer.Serialize(node);
+ Console.WriteLine($"JSON: {json}");
+
+ var deserialized = AcJsonDeserializer.Deserialize(json);
+
+ // Assert
+ Assert.IsNotNull(deserialized);
+ Assert.AreEqual(ConstantValueType.Guid, deserialized.ValueType);
+
+ var value = deserialized.GetValue();
+ Assert.IsNotNull(value);
+ Assert.AreEqual(typeof(Guid), value.GetType(), "Should preserve Guid type");
+ Assert.AreEqual(testGuid, (Guid)value);
+ }
+
+ #endregion
+
+ #region Binary Serializer Tests
+
+ ///
+ /// Tests AcBinarySerializer with AcExpressionNode round-trip.
+ ///
+ [TestMethod]
+ public void AcBinarySerializer_WithAcExpressionNode_RoundTrip_Works()
+ {
+ // Arrange
+ System.Linq.Expressions.Expression> filterExpression =
+ item => item.Quantity > 5;
+
+ var originalNode = AcExpressionConverter.ToNode(filterExpression);
+
+ // Act - Binary round-trip
+ var bytes = AcBinarySerializer.Serialize(originalNode);
+ Console.WriteLine($"Binary size: {bytes.Length} bytes");
+
+ var deserialized = AcBinaryDeserializer.Deserialize(bytes);
+
+ // Assert
+ Assert.IsNotNull(deserialized);
+ Assert.AreEqual(originalNode.NodeType, deserialized.NodeType);
+
+ // Rebuild and test
+ var rebuiltExpression = AcExpressionRebuilder.FromNode(deserialized);
+ var compiled = rebuiltExpression.Compile();
+
+ var matchingItem = new TestOrderItem { Id = 1, Quantity = 10 };
+ var nonMatchingItem = new TestOrderItem { Id = 2, Quantity = 3 };
+
+ Assert.IsTrue(compiled(matchingItem), "Matching item should pass filter");
+ Assert.IsFalse(compiled(nonMatchingItem), "Non-matching item should fail filter");
+ }
+
+ ///
+ /// Tests AcBinarySerializer with decimal in typed property.
+ ///
+ [TestMethod]
+ public void AcBinarySerializer_WithDecimalValue_PreservesType()
+ {
+ // Arrange
+ var node = new AcExpressionNode
+ {
+ NodeType = System.Linq.Expressions.ExpressionType.Constant,
+ TypeName = "System.Decimal"
+ };
+ node.SetValue(99.99m);
+
+ // Act
+ var bytes = AcBinarySerializer.Serialize(node);
+ var deserialized = AcBinaryDeserializer.Deserialize(bytes);
+
+ // Assert
+ Assert.IsNotNull(deserialized);
+ Assert.AreEqual(ConstantValueType.Decimal, deserialized.ValueType);
+
+ var value = deserialized.GetValue();
+ Assert.IsNotNull(value);
+ Assert.AreEqual(typeof(decimal), value.GetType(), "Binary should preserve decimal type");
+ Assert.AreEqual(99.99m, (decimal)value);
+ }
+
+ ///
+ /// Tests complex expression with decimal comparison.
+ ///
+ [TestMethod]
+ public void AcBinarySerializer_WithDecimalComparison_RoundTrip_Works()
+ {
+ // Arrange - Expression with captured decimal: item => item.UnitPrice > 99.99m
+ var minPrice = 99.99m;
+ System.Linq.Expressions.Expression> filterExpression =
+ item => item.UnitPrice > minPrice;
+
+ var originalNode = AcExpressionConverter.ToNode(filterExpression);
+
+ // Act - Binary round-trip
+ var bytes = AcBinarySerializer.Serialize(originalNode);
+ var deserializedNode = AcBinaryDeserializer.Deserialize(bytes);
+
+ // Assert - Rebuild and verify it still works with decimal comparison
+ Assert.IsNotNull(deserializedNode);
+
+ var rebuiltExpression = AcExpressionRebuilder.FromNode(deserializedNode);
+ var compiledFilter = rebuiltExpression.Compile();
+
+ var expensiveItem = new TestOrderItem { UnitPrice = 150m };
+ var cheapItem = new TestOrderItem { UnitPrice = 50m };
+
+ Assert.IsTrue(compiledFilter(expensiveItem), "Expensive item should pass filter");
+ Assert.IsFalse(compiledFilter(cheapItem), "Cheap item should fail filter");
+ }
+
+ ///
+ /// Tests enum constant values.
+ ///
+ [TestMethod]
+ public void AcBinarySerializer_WithEnumValue_PreservesType()
+ {
+ // Arrange - Expression with enum comparison
+ System.Linq.Expressions.Expression> filterExpression =
+ item => item.Status == TestStatus.Completed;
+
+ var originalNode = AcExpressionConverter.ToNode(filterExpression);
+
+ // Act - Binary round-trip
+ var bytes = AcBinarySerializer.Serialize(originalNode);
+ var deserializedNode = AcBinaryDeserializer.Deserialize(bytes);
+
+ // Assert
+ Assert.IsNotNull(deserializedNode);
+
+ var rebuiltExpression = AcExpressionRebuilder.FromNode(deserializedNode);
+ var compiledFilter = rebuiltExpression.Compile();
+
+ var completedItem = new TestOrderItem { Status = TestStatus.Completed };
+ var pendingItem = new TestOrderItem { Status = TestStatus.Pending };
+
+ Assert.IsTrue(compiledFilter(completedItem), "Completed item should pass filter");
+ Assert.IsFalse(compiledFilter(pendingItem), "Pending item should fail filter");
+ }
+
+ #endregion
+
+ #region SetValue/GetValue Tests
+
+ [TestMethod]
+ [DataRow(42, ConstantValueType.Int32)]
+ [DataRow(long.MaxValue, ConstantValueType.Int64)]
+ [DataRow(3.14, ConstantValueType.Double)]
+ [DataRow(true, ConstantValueType.Boolean)]
+ [DataRow("test", ConstantValueType.String)]
+ public void SetValue_GetValue_RoundTrip_PreservesValue(object value, ConstantValueType expectedType)
+ {
+ // Arrange
+ var node = new AcExpressionNode();
+
+ // Act
+ node.SetValue(value);
+ var result = node.GetValue();
+
+ // Assert
+ Assert.AreEqual(expectedType, node.ValueType);
+ Assert.AreEqual(value, result);
+ }
+
+ [TestMethod]
+ public void SetValue_Null_SetsNullType()
+ {
+ var node = new AcExpressionNode();
+ node.SetValue(null);
+
+ Assert.AreEqual(ConstantValueType.Null, node.ValueType);
+ Assert.IsNull(node.GetValue());
+ }
+
+ [TestMethod]
+ public void SetValue_Guid_PreservesValue()
+ {
+ var guid = Guid.NewGuid();
+ var node = new AcExpressionNode();
+ node.SetValue(guid);
+
+ Assert.AreEqual(ConstantValueType.Guid, node.ValueType);
+ Assert.AreEqual(guid, node.GetValue());
+ }
+
+ [TestMethod]
+ public void SetValue_DateTime_PreservesValue()
+ {
+ var dateTime = new DateTime(2024, 6, 15, 10, 30, 45, DateTimeKind.Utc);
+ var node = new AcExpressionNode();
+ node.SetValue(dateTime);
+
+ Assert.AreEqual(ConstantValueType.DateTime, node.ValueType);
+ Assert.AreEqual(dateTime, node.GetValue());
+ }
+
+ [TestMethod]
+ public void SetValue_Decimal_PreservesValue()
+ {
+ var value = 123.456789m;
+ var node = new AcExpressionNode();
+ node.SetValue(value);
+
+ Assert.AreEqual(ConstantValueType.Decimal, node.ValueType);
+ Assert.AreEqual(value, node.GetValue());
+ }
+
+ [TestMethod]
+ public void SetValue_Enum_PreservesValue()
+ {
+ var node = new AcExpressionNode();
+ node.SetValue(TestStatus.Processing);
+
+ Assert.AreEqual(ConstantValueType.Enum, node.ValueType);
+ Assert.IsTrue(node.EnumTypeName!.StartsWith("AyCode.Core.Tests.TestModels.TestStatus"),
+ $"EnumTypeName should start with full type name, got: {node.EnumTypeName}");
+ Assert.AreEqual(TestStatus.Processing, node.GetValue());
+ }
+
+ #endregion
+}
diff --git a/AyCode.Core.Tests/Serialization/AcJsonSerializerChainTests.cs b/AyCode.Core.Tests/Serialization/AcJsonSerializerChainTests.cs
new file mode 100644
index 0000000..4e1e3e5
--- /dev/null
+++ b/AyCode.Core.Tests/Serialization/AcJsonSerializerChainTests.cs
@@ -0,0 +1,329 @@
+using AyCode.Core.Extensions;
+using AyCode.Core.Serializers.Jsons;
+using AyCode.Core.Tests.TestModels;
+using static AyCode.Core.Tests.TestModels.AcSerializerModels;
+
+namespace AyCode.Core.Tests.Serialization;
+
+///
+/// Tests for JSON Chain API (CreateDeserializeChain and CreatePopulateChain).
+///
+[TestClass]
+public class AcJsonSerializerChainTests
+{
+ [TestMethod]
+ public void DeserializeChain_SingleDeserialization_WorksCorrectly()
+ {
+ // Arrange
+ var original = new TestSimpleClass { Id = 42, Name = "John", Value = 3.14, IsActive = true };
+ var json = original.ToJson();
+
+ // Act
+ using var chain = json.JsonToChain();
+ var result = chain.Value;
+
+ // Assert
+ Assert.IsNotNull(result);
+ Assert.AreEqual(42, result.Id);
+ Assert.AreEqual("John", result.Name);
+ Assert.AreEqual(3.14, result.Value);
+ Assert.AreEqual(true, result.IsActive);
+ }
+
+ [TestMethod]
+ public void DeserializeChain_MultipleDeserializations_ParsesOnlyOnce()
+ {
+ // Arrange
+ var json = """{"Id":100,"Name":"Test","Value":99.9,"IsActive":false}""";
+
+ // Act
+ using var chain = json.JsonToChain();
+ var result1 = chain.Value;
+ var result2 = chain.ThenDeserialize();
+ var result3 = chain.ThenDeserialize();
+
+ // Assert - All three deserializations should work
+ Assert.IsNotNull(result1);
+ Assert.AreEqual(100, result1.Id);
+
+ Assert.IsNotNull(result2);
+ Assert.AreEqual(100, result2.Id);
+ Assert.AreEqual("Test", result2.Name);
+
+ Assert.IsNotNull(result3);
+ Assert.AreEqual(99.9, result3.Value);
+ Assert.AreEqual(false, result3.IsActive);
+ }
+
+ [TestMethod]
+ public void DeserializeChain_DifferentTypes_WorksCorrectly()
+ {
+ // Arrange
+ var json = """{"Id":1,"Name":"Product","Value":29.99}""";
+
+ // Act
+ using var chain = json.JsonToChain();
+ var asClass = chain.Value;
+ var asDict = chain.ThenDeserialize>();
+
+ // Assert
+ Assert.IsNotNull(asClass);
+ Assert.AreEqual(1, asClass.Id);
+ Assert.AreEqual("Product", asClass.Name);
+
+ Assert.IsNotNull(asDict);
+ Assert.AreEqual(3, asDict.Count);
+ Assert.IsTrue(asDict.ContainsKey("Id"));
+ Assert.IsTrue(asDict.ContainsKey("Name"));
+ }
+
+ [TestMethod]
+ public void DeserializeChain_NestedObjects_WorksCorrectly()
+ {
+ // Arrange
+ var original = new TestNestedClass
+ {
+ Id = 1,
+ Name = "Parent",
+ Child = new TestSimpleClass { Id = 2, Name = "Child", Value = 10.5 }
+ };
+ var json = original.ToJson();
+
+ // Act
+ using var chain = json.JsonToChain();
+ var result1 = chain.Value;
+ var result2 = chain.ThenDeserialize();
+
+ // Assert
+ Assert.IsNotNull(result1);
+ Assert.AreEqual("Parent", result1.Name);
+ Assert.IsNotNull(result1.Child);
+ Assert.AreEqual("Child", result1.Child.Name);
+
+ Assert.IsNotNull(result2);
+ Assert.AreEqual(1, result2.Id);
+ Assert.IsNotNull(result2.Child);
+ Assert.AreEqual(10.5, result2.Child.Value);
+ }
+
+ [TestMethod]
+ public void DeserializeChain_WithList_WorksCorrectly()
+ {
+ // Arrange
+ var original = new TestClassWithList
+ {
+ Id = 5,
+ Items = new List { "Apple", "Banana", "Cherry" }
+ };
+ var json = original.ToJson();
+
+ // Act
+ using var chain = json.JsonToChain();
+ var result = chain.Value;
+
+ // Assert
+ Assert.IsNotNull(result);
+ Assert.AreEqual(5, result.Id);
+ Assert.AreEqual(3, result.Items.Count);
+ Assert.AreEqual("Apple", result.Items[0]);
+ Assert.AreEqual("Banana", result.Items[1]);
+ Assert.AreEqual("Cherry", result.Items[2]);
+ }
+
+ [TestMethod]
+ public void PopulateChain_SinglePopulate_UpdatesObject()
+ {
+ // Arrange
+ var json = """{"Id":99,"Name":"Updated","Value":123.45,"IsActive":true}""";
+ var target = new TestSimpleClass { Id = 1, Name = "Old", Value = 0, IsActive = false };
+
+ // Act
+ using var chain = json.JsonToChain(target);
+
+ // Assert
+ Assert.AreEqual(99, target.Id);
+ Assert.AreEqual("Updated", target.Name);
+ Assert.AreEqual(123.45, target.Value);
+ Assert.AreEqual(true, target.IsActive);
+ }
+
+ [TestMethod]
+ public void PopulateChain_MultiplePopulates_UpdatesAllObjects()
+ {
+ // Arrange
+ var json = """{"Id":100,"Name":"Shared","Value":50.0}""";
+ var target1 = new TestSimpleClass { Id = 1, Name = "Old1" };
+ var target2 = new TestSimpleClass { Id = 2, Name = "Old2" };
+ var target3 = new TestSimpleClass { Id = 3, Name = "Old3" };
+
+ // Act
+ using var chain = json.JsonToChain(target1);
+ chain.ThenPopulate(target2);
+ chain.ThenPopulate(target3);
+
+ // Assert
+ Assert.AreEqual(100, target1.Id);
+ Assert.AreEqual("Shared", target1.Name);
+ Assert.AreEqual(50.0, target1.Value);
+
+ Assert.AreEqual(100, target2.Id);
+ Assert.AreEqual("Shared", target2.Name);
+ Assert.AreEqual(50.0, target2.Value);
+
+ Assert.AreEqual(100, target3.Id);
+ Assert.AreEqual("Shared", target3.Name);
+ Assert.AreEqual(50.0, target3.Value);
+ }
+
+ [TestMethod]
+ public void PopulateChain_NestedObjects_MergesCorrectly()
+ {
+ // Arrange
+ var json = """{"Id":10,"Name":"UpdatedParent","Child":{"Id":20,"Name":"UpdatedChild","Value":99.9}}""";
+ var target = new TestNestedClass
+ {
+ Id = 1,
+ Name = "OldParent",
+ Child = new TestSimpleClass { Id = 2, Name = "OldChild", Value = 1.0 }
+ };
+
+ // Act
+ using var chain = json.JsonToChain(target);
+
+ // Assert
+ Assert.AreEqual(10, target.Id);
+ Assert.AreEqual("UpdatedParent", target.Name);
+ Assert.IsNotNull(target.Child);
+ Assert.AreEqual(20, target.Child.Id);
+ Assert.AreEqual("UpdatedChild", target.Child.Name);
+ Assert.AreEqual(99.9, target.Child.Value);
+ }
+
+ [TestMethod]
+ public void PopulateChain_WithList_UpdatesCollection()
+ {
+ // Arrange
+ var json = """{"Id":7,"Items":["New1","New2","New3"]}""";
+ var target = new TestClassWithList
+ {
+ Id = 1,
+ Items = new List { "Old1" }
+ };
+
+ // Act
+ using var chain = json.JsonToChain(target);
+
+ // Assert
+ Assert.AreEqual(7, target.Id);
+ Assert.AreEqual(3, target.Items.Count);
+ Assert.AreEqual("New1", target.Items[0]);
+ Assert.AreEqual("New2", target.Items[1]);
+ Assert.AreEqual("New3", target.Items[2]);
+ }
+
+ [TestMethod]
+ public void DeserializeChain_EmptyJson_ReturnsEmpty()
+ {
+ // Arrange
+ var json = "";
+
+ // Act
+ using var chain = json.JsonToChain();
+
+ // Assert
+ Assert.IsNull(chain.Value);
+ }
+
+ [TestMethod]
+ public void DeserializeChain_NullJson_ReturnsEmpty()
+ {
+ // Arrange
+ var json = "null";
+
+ // Act
+ using var chain = json.JsonToChain();
+
+ // Assert
+ Assert.IsNull(chain.Value);
+ }
+
+ [TestMethod]
+ public void PopulateChain_EmptyJson_DoesNothing()
+ {
+ // Arrange
+ var json = "";
+ var target = new TestSimpleClass { Id = 42, Name = "Original" };
+
+ // Act
+ using var chain = json.JsonToChain(target);
+
+ // Assert - Should remain unchanged
+ Assert.AreEqual(42, target.Id);
+ Assert.AreEqual("Original", target.Name);
+ }
+
+ [TestMethod]
+ public void DeserializeChain_Dispose_CannotReuseAfterDispose()
+ {
+ // Arrange
+ var json = """{"Id":1,"Name":"Test"}""";
+ var chain = json.JsonToChain();
+ var value = chain.Value;
+
+ // Act
+ chain.Dispose();
+
+ // Assert
+ Assert.IsNotNull(value); // Value from before dispose should still exist
+ _ = Assert.ThrowsExactly(() => chain.ThenDeserialize());
+ }
+
+ [TestMethod]
+ public void PopulateChain_Dispose_CannotReuseAfterDispose()
+ {
+ // Arrange
+ var json = """{"Id":1,"Name":"Test"}""";
+ var target1 = new TestSimpleClass();
+ var chain = json.JsonToChain(target1);
+
+ // Act
+ chain.Dispose();
+
+ // Assert
+ var target2 = new TestSimpleClass();
+ _ = Assert.ThrowsExactly(() => chain.ThenPopulate(target2));
+ }
+
+ [TestMethod]
+ public void DeserializeChain_WithOptions_UsesCorrectOptions()
+ {
+ // Arrange
+ var json = """{"Id":1,"Name":"Test","Value":10.5}""";
+ var options = new AcJsonSerializerOptions { MaxDepth = 10 };
+
+ // Act
+ using var chain = json.JsonToChain(options);
+ var result = chain.Value;
+
+ // Assert
+ Assert.IsNotNull(result);
+ Assert.AreEqual(1, result.Id);
+ Assert.AreEqual("Test", result.Name);
+ }
+
+ [TestMethod]
+ public void PopulateChain_WithOptions_UsesCorrectOptions()
+ {
+ // Arrange
+ var json = """{"Id":99,"Name":"Updated"}""";
+ var target = new TestSimpleClass { Id = 1, Name = "Old" };
+ var options = new AcJsonSerializerOptions { MaxDepth = 10 };
+
+ // Act
+ using var chain = json.JsonToChain(target, options);
+
+ // Assert
+ Assert.AreEqual(99, target.Id);
+ Assert.AreEqual("Updated", target.Name);
+ }
+}
diff --git a/AyCode.Core.Tests/Serialization/AcSerializerTestHelper.cs b/AyCode.Core.Tests/Serialization/AcSerializerTestHelper.cs
index 991dfda..3378df5 100644
--- a/AyCode.Core.Tests/Serialization/AcSerializerTestHelper.cs
+++ b/AyCode.Core.Tests/Serialization/AcSerializerTestHelper.cs
@@ -1,4 +1,4 @@
-using static AyCode.Core.Tests.Serialization.AcSerializerModels;
+using static AyCode.Core.Tests.TestModels.AcSerializerModels;
namespace AyCode.Core.Tests.Serialization;
diff --git a/AyCode.Core.Tests/Serialization/AcSerializerModels.cs b/AyCode.Core.Tests/TestModels/AcSerializerModels.cs
similarity index 99%
rename from AyCode.Core.Tests/Serialization/AcSerializerModels.cs
rename to AyCode.Core.Tests/TestModels/AcSerializerModels.cs
index d48d6d6..77b2512 100644
--- a/AyCode.Core.Tests/Serialization/AcSerializerModels.cs
+++ b/AyCode.Core.Tests/TestModels/AcSerializerModels.cs
@@ -1,4 +1,4 @@
-namespace AyCode.Core.Tests.Serialization;
+namespace AyCode.Core.Tests.TestModels;
///
/// Test models for binary serializer tests.
diff --git a/AyCode.Core.Tests/Serialization/StockTakingTestModels.cs b/AyCode.Core.Tests/TestModels/StockTakingTestModels.cs
similarity index 98%
rename from AyCode.Core.Tests/Serialization/StockTakingTestModels.cs
rename to AyCode.Core.Tests/TestModels/StockTakingTestModels.cs
index 87f21d4..3259f32 100644
--- a/AyCode.Core.Tests/Serialization/StockTakingTestModels.cs
+++ b/AyCode.Core.Tests/TestModels/StockTakingTestModels.cs
@@ -1,12 +1,10 @@
-using AyCode.Interfaces.Entities;
+using System.ComponentModel.DataAnnotations.Schema;
+using AyCode.Interfaces.Entities;
using AyCode.Interfaces.TimeStampInfo;
using Newtonsoft.Json;
-using System.ComponentModel.DataAnnotations;
-using System.ComponentModel.DataAnnotations.Schema;
-using System.Linq.Expressions;
using static System.Net.Mime.MediaTypeNames;
-namespace AyCode.Core.Tests.Serialization;
+namespace AyCode.Core.Tests.TestModels;
public abstract partial class BaseEntity : IBaseEntity
diff --git a/AyCode.Core.Tests/TestModels/TestLogger.cs b/AyCode.Core.Tests/TestModels/TestLogger.cs
index ff260ba..2f434aa 100644
--- a/AyCode.Core.Tests/TestModels/TestLogger.cs
+++ b/AyCode.Core.Tests/TestModels/TestLogger.cs
@@ -5,7 +5,7 @@ using AyCode.Core.Loggers;
namespace AyCode.Core.Tests.TestModels;
///
-/// Test logger that captures log messages for assertions.
+/// Test logger that captures log messages for assertions and writes to console.
/// Does not require configuration or log writers.
///
public class TestLogger : AcLoggerBase
@@ -17,22 +17,48 @@ public class TestLogger : AcLoggerBase
}
public override void Detail(string? text, string? categoryName = null, [CallerMemberName] string? memberName = null)
- => Logs.Add(new LogEntry(LogLevel.Detail, text, categoryName, memberName));
+ {
+ var entry = new LogEntry(LogLevel.Detail, text, categoryName, memberName);
+ Logs.Add(entry);
+ Console.WriteLine(entry);
+ }
public override void Debug(string? text, string? categoryName = null, [CallerMemberName] string? memberName = null)
- => Logs.Add(new LogEntry(LogLevel.Debug, text, categoryName, memberName));
+ {
+ var entry = new LogEntry(LogLevel.Debug, text, categoryName, memberName);
+ Logs.Add(entry);
+ Console.WriteLine(entry);
+ }
public override void Info(string? text, string? categoryName = null, [CallerMemberName] string? memberName = null)
- => Logs.Add(new LogEntry(LogLevel.Info, text, categoryName, memberName));
+ {
+ var entry = new LogEntry(LogLevel.Info, text, categoryName, memberName);
+ Logs.Add(entry);
+ Console.WriteLine(entry);
+ }
public override void Warning(string? text, string? categoryName = null, [CallerMemberName] string? memberName = null)
- => Logs.Add(new LogEntry(LogLevel.Warning, text, categoryName, memberName));
+ {
+ var entry = new LogEntry(LogLevel.Warning, text, categoryName, memberName);
+ Logs.Add(entry);
+ Console.WriteLine(entry);
+ }
public override void Suggest(string? text, string? categoryName = null, [CallerMemberName] string? memberName = null)
- => Logs.Add(new LogEntry(LogLevel.Suggest, text, categoryName, memberName));
+ {
+ var entry = new LogEntry(LogLevel.Suggest, text, categoryName, memberName);
+ Logs.Add(entry);
+ Console.WriteLine(entry);
+ }
public override void Error(string? text, Exception? ex = null, string? categoryName = null, [CallerMemberName] string? memberName = null)
- => Logs.Add(new LogEntry(LogLevel.Error, text, categoryName, memberName, ex));
+ {
+ var entry = new LogEntry(LogLevel.Error, text, categoryName, memberName, ex);
+ Logs.Add(entry);
+ Console.WriteLine(entry);
+ if (ex != null)
+ Console.WriteLine($" Exception: {ex.Message}");
+ }
public void Clear() => Logs.Clear();
diff --git a/AyCode.Core.sln b/AyCode.Core.sln
index 08722dd..3caa24d 100644
--- a/AyCode.Core.sln
+++ b/AyCode.Core.sln
@@ -119,6 +119,7 @@ Global
{320A245F-6731-476D-A9D8-77888E6B5D9C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{320A245F-6731-476D-A9D8-77888E6B5D9C}.Product|Any CPU.ActiveCfg = Product|Any CPU
{320A245F-6731-476D-A9D8-77888E6B5D9C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {320A245F-6731-476D-A9D8-77888E6B5D9C}.Release|Any CPU.Build.0 = Release|Any CPU
{21392620-7D0E-44B6-9485-93C57F944C20}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{21392620-7D0E-44B6-9485-93C57F944C20}.Debug|Any CPU.Build.0 = Debug|Any CPU
{21392620-7D0E-44B6-9485-93C57F944C20}.Product|Any CPU.ActiveCfg = Product|Any CPU
diff --git a/AyCode.Core/AyCode.Core.csproj b/AyCode.Core/AyCode.Core.csproj
index 6496fa0..5a7c5eb 100644
--- a/AyCode.Core/AyCode.Core.csproj
+++ b/AyCode.Core/AyCode.Core.csproj
@@ -17,4 +17,8 @@
+
+
+
+
diff --git a/AyCode.Core/Extensions/SerializeObjectExtensions.cs b/AyCode.Core/Extensions/SerializeObjectExtensions.cs
index ab9ad39..72008ef 100644
--- a/AyCode.Core/Extensions/SerializeObjectExtensions.cs
+++ b/AyCode.Core/Extensions/SerializeObjectExtensions.cs
@@ -447,6 +447,46 @@ public static class SerializeObjectExtensions
AcJsonDeserializer.Populate(json, target, options);
}
+ ///
+ /// Create a deserialize chain that parses JSON once and allows multiple deserializations.
+ /// Efficient for deserializing the same JSON to multiple different types.
+ /// Use with 'using' statement or call Dispose() when done.
+ ///
+ public static IDeserializeChain JsonToChain(this string json)
+ {
+ json = UnwrapJsonString(json);
+ return AcJsonDeserializer.CreateDeserializeChain(json);
+ }
+
+ ///
+ /// Create a deserialize chain with options.
+ ///
+ public static IDeserializeChain JsonToChain(this string json, AcJsonSerializerOptions options)
+ {
+ json = UnwrapJsonString(json);
+ return AcJsonDeserializer.CreateDeserializeChain(json, options);
+ }
+
+ ///
+ /// Create a populate chain that parses JSON once and allows populating multiple objects.
+ /// Efficient for populating multiple objects from the same JSON source.
+ /// Use with 'using' statement or call Dispose() when done.
+ ///
+ public static IPopulateChain JsonToChain(this string json, object target)
+ {
+ json = UnwrapJsonString(json);
+ return AcJsonDeserializer.CreatePopulateChain(json, target);
+ }
+
+ ///
+ /// Create a populate chain with options.
+ ///
+ public static IPopulateChain JsonToChain(this string json, object target, AcJsonSerializerOptions options)
+ {
+ json = UnwrapJsonString(json);
+ return AcJsonDeserializer.CreatePopulateChain(json, target, options);
+ }
+
#endregion
#region Any (JSON or Binary based on options)
diff --git a/AyCode.Core/Serializers/AcSerializerCommon.cs b/AyCode.Core/Serializers/AcSerializerCommon.cs
new file mode 100644
index 0000000..ede31b6
--- /dev/null
+++ b/AyCode.Core/Serializers/AcSerializerCommon.cs
@@ -0,0 +1,480 @@
+using System.Collections;
+using System.Linq.Expressions;
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using AyCode.Core.Serializers.Expressions;
+using LExpression = System.Linq.Expressions.Expression;
+using LExpressionType = System.Linq.Expressions.ExpressionType;
+
+namespace AyCode.Core.Serializers;
+
+///
+/// Common utilities shared across all serializers (JSON, Binary).
+/// Centralizes expression compilation helpers to eliminate code duplication.
+/// Type references and caches remain in JsonUtilities for backward compatibility.
+///
+public static class AcSerializerCommon
+{
+ #region Type References
+
+ ///
+ /// Expression base type for type checking.
+ ///
+ public static readonly Type ExpressionType = typeof(LExpression);
+
+ ///
+ /// Generic Expression<TDelegate> type definition.
+ ///
+ public static readonly Type ExpressionGenericType = typeof(Expression<>);
+
+ ///
+ /// Generic IQueryable<T> type definition.
+ ///
+ public static readonly Type QueryableGenericType = typeof(IQueryable<>);
+
+ ///
+ /// Non-generic IQueryable type.
+ ///
+ public static readonly Type QueryableType = typeof(IQueryable);
+
+ #endregion
+
+ #region Type Checking
+
+ ///
+ /// Checks if a type is an Expression type.
+ /// Shared across JSON and Binary serializers.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static bool IsExpressionType(Type type)
+ {
+ return ExpressionType.IsAssignableFrom(type);
+ }
+
+ ///
+ /// Checks if a type is Expression<TDelegate> (e.g., Expression<Func<T, bool>>).
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static bool IsGenericExpressionType(Type type)
+ {
+ return type.IsGenericType && type.GetGenericTypeDefinition() == ExpressionGenericType;
+ }
+
+ ///
+ /// Checks if a type is IQueryable or IQueryable<T>.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static bool IsQueryableType(Type type)
+ {
+ if (QueryableType.IsAssignableFrom(type))
+ return true;
+ if (type.IsGenericType && type.GetGenericTypeDefinition() == QueryableGenericType)
+ return true;
+ return false;
+ }
+
+ ///
+ /// Gets the entity type from IQueryable<TEntity>.
+ /// Returns null if not a valid queryable type.
+ ///
+ public static Type? GetQueryableEntityType(Type queryableType)
+ {
+ if (queryableType.IsGenericType && queryableType.GetGenericTypeDefinition() == QueryableGenericType)
+ return queryableType.GetGenericArguments()[0];
+
+ // Check interfaces for IQueryable
+ foreach (var iface in queryableType.GetInterfaces())
+ {
+ if (iface.IsGenericType && iface.GetGenericTypeDefinition() == QueryableGenericType)
+ return iface.GetGenericArguments()[0];
+ }
+
+ return null;
+ }
+
+ ///
+ /// Gets the entity type from Expression<Func<TEntity, TResult>>.
+ /// Returns null if not a valid expression type.
+ ///
+ public static Type? GetExpressionEntityType(Type expressionType)
+ {
+ if (!IsGenericExpressionType(expressionType))
+ return null;
+
+ var delegateType = expressionType.GetGenericArguments()[0];
+ if (!delegateType.IsGenericType)
+ return null;
+
+ var delegateArgs = delegateType.GetGenericArguments();
+ return delegateArgs.Length > 0 ? delegateArgs[0] : null;
+ }
+
+ #endregion
+
+ #region IQueryable Serialization
+
+ ///
+ /// Converts an IQueryable to AcExpressionNode for serialization.
+ /// The query's expression tree is serialized.
+ ///
+ public static AcExpressionNode QueryableToNode(IQueryable queryable)
+ {
+ return AcExpressionConverter.ToNode(queryable.Expression);
+ }
+
+ ///
+ /// Applies an AcExpressionNode to an IQueryable source.
+ /// Used to rebuild the query on the server side.
+ ///
+ public static IQueryable ApplyQueryFromNode(IQueryable source, AcExpressionNode node)
+ {
+ var expression = RebuildQueryExpression(source.Expression, node, typeof(T));
+ return source.Provider.CreateQuery(expression);
+ }
+
+ ///
+ /// Executes an aggregate query (Count, Sum, Any, etc.) from an AcExpressionNode.
+ /// Returns the scalar result of the query.
+ ///
+ public static object? ExecuteQueryFromNode(IQueryable source, AcExpressionNode node)
+ {
+ var expression = RebuildQueryExpression(source.Expression, node, typeof(T));
+ return source.Provider.Execute(expression);
+ }
+
+ ///
+ /// Rebuilds a query expression, replacing the source with the provided expression.
+ ///
+ private static LExpression RebuildQueryExpression(LExpression sourceExpression, AcExpressionNode node, Type entityType)
+ {
+ if (node is { NodeType: LExpressionType.Call, MethodName: not null })
+ {
+ return RebuildMethodCallChain(sourceExpression, node, entityType);
+ }
+
+ // If it's just a lambda (filter expression), wrap it in a Where call
+ if (node.NodeType == LExpressionType.Lambda)
+ {
+ var lambda = AcExpressionRebuilder.FromNode(node, entityType);
+
+ var whereMethod = typeof(Queryable).GetMethods()
+ .First(m => m.Name == "Where" && m.GetParameters().Length == 2)
+ .MakeGenericMethod(entityType);
+
+ return LExpression.Call(whereMethod, sourceExpression, lambda);
+ }
+
+ throw new NotSupportedException($"Cannot apply expression of type '{node.NodeType}' to IQueryable.");
+ }
+
+ ///
+ /// Rebuilds a chain of method calls (Where, OrderBy, Skip, Take, etc.)
+ /// Automatically replaces the original IQueryable source with the server's source.
+ ///
+ private static LExpression RebuildMethodCallChain(LExpression sourceExpression, AcExpressionNode node, Type entityType)
+ {
+ // First, process the inner expression (the source of this method call)
+ LExpression currentSource;
+
+ if (node.Arguments?.Count > 0)
+ {
+ var firstArg = node.Arguments[0];
+
+ // Check if first argument is another method call (recursive chain)
+ if (firstArg.NodeType == LExpressionType.Call)
+ {
+ currentSource = RebuildMethodCallChain(sourceExpression, firstArg, entityType);
+ }
+ // Check if first argument is a Constant (this is the original IQueryable source - replace it)
+ else if (firstArg.NodeType == LExpressionType.Constant)
+ {
+ // The original source (e.g., empty list from client) - replace with server source
+ currentSource = sourceExpression;
+ }
+ else
+ {
+ // Other cases - use the provided source
+ currentSource = sourceExpression;
+ }
+ }
+ else
+ {
+ currentSource = sourceExpression;
+ }
+
+ // Now apply this method call
+ var methodName = node.MethodName!;
+ var declaringType = ResolveDeclaringType(node.DeclaringType);
+
+ // Find the method
+ var method = FindQueryableMethod(declaringType, methodName, node.GenericArguments, node.Arguments?.Count ?? 1);
+
+ if (method == null)
+ throw new InvalidOperationException($"Method '{methodName}' not found on type '{declaringType.FullName}'.");
+
+ // Apply generic type arguments
+ if (method.IsGenericMethodDefinition && node.GenericArguments?.Count > 0)
+ {
+ var genericTypes = node.GenericArguments.Select(t => ResolveTypeName(t) ?? entityType).ToArray();
+ method = method.MakeGenericMethod(genericTypes);
+ }
+
+ // Build arguments - first argument is always the source
+ var arguments = new List { currentSource };
+
+ // Process remaining arguments (skip first - it's the source we already handled)
+ if (node.Arguments?.Count > 1)
+ {
+ for (var i = 1; i < node.Arguments.Count; i++)
+ {
+ var argNode = node.Arguments[i];
+
+ if (argNode.NodeType == LExpressionType.Quote && argNode.Operand != null)
+ {
+ // Quoted lambda - unquote and deserialize
+ var lambda = AcExpressionRebuilder.FromNode(argNode.Operand, entityType);
+ arguments.Add(LExpression.Quote(lambda));
+ }
+ else if (argNode.NodeType == LExpressionType.Lambda)
+ {
+ // Lambda needs to be quoted for Queryable methods
+ var lambda = AcExpressionRebuilder.FromNode(argNode, entityType);
+ arguments.Add(LExpression.Quote(lambda));
+ }
+ else
+ {
+ // Other arguments (e.g., int for Skip/Take)
+ arguments.Add(AcExpressionRebuilder.FromNode(argNode, entityType));
+ }
+ }
+ }
+
+ return LExpression.Call(method, arguments);
+ }
+
+ ///
+ /// Resolves the declaring type from the type name.
+ /// Supports AssemblyQualifiedName, FullName, and searches all loaded assemblies as fallback.
+ /// Works with any ORM extension methods (EF Core Include, linq2db LoadWith, etc.)
+ ///
+ private static Type ResolveDeclaringType(string? typeName)
+ {
+ if (string.IsNullOrEmpty(typeName))
+ return typeof(Queryable);
+
+ // Try direct resolution first (works for AssemblyQualifiedName)
+ var type = Type.GetType(typeName);
+ if (type != null)
+ return type;
+
+ // Extract the type name without assembly info for searching
+ var typeNameOnly = typeName.Contains(',')
+ ? typeName.Substring(0, typeName.IndexOf(','))
+ : typeName;
+
+ // Fast path for common LINQ types
+ if (typeNameOnly is "System.Linq.Queryable") return typeof(Queryable);
+ if (typeNameOnly is "System.Linq.Enumerable") return typeof(Enumerable);
+
+ // Search all loaded assemblies (works for FullName when assembly is loaded)
+ foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
+ {
+ type = assembly.GetType(typeNameOnly);
+ if (type != null)
+ return type;
+ }
+
+ // Fallback to Queryable
+ return typeof(Queryable);
+ }
+
+ ///
+ /// Resolves a type from its name.
+ /// Supports AssemblyQualifiedName, FullName, and searches all loaded assemblies as fallback.
+ ///
+ private static Type? ResolveTypeName(string typeName)
+ {
+ // Try direct resolution first (works for AssemblyQualifiedName)
+ var type = Type.GetType(typeName);
+ if (type != null)
+ return type;
+
+ // Extract the type name without assembly info
+ var typeNameOnly = typeName.Contains(',')
+ ? typeName.Substring(0, typeName.IndexOf(','))
+ : typeName;
+
+ // Fast path for common types
+ type = typeNameOnly switch
+ {
+ "System.String" or "string" => typeof(string),
+ "System.Int32" or "int" => typeof(int),
+ "System.Int64" or "long" => typeof(long),
+ "System.Int16" or "short" => typeof(short),
+ "System.Byte" or "byte" => typeof(byte),
+ "System.SByte" or "sbyte" => typeof(sbyte),
+ "System.Boolean" or "bool" => typeof(bool),
+ "System.Double" or "double" => typeof(double),
+ "System.Single" or "float" => typeof(float),
+ "System.Decimal" or "decimal" => typeof(decimal),
+ "System.DateTime" => typeof(DateTime),
+ "System.DateTimeOffset" => typeof(DateTimeOffset),
+ "System.TimeSpan" => typeof(TimeSpan),
+ "System.DateOnly" => typeof(DateOnly),
+ "System.TimeOnly" => typeof(TimeOnly),
+ "System.Guid" => typeof(Guid),
+ "System.Object" or "object" => typeof(object),
+ _ => null
+ };
+
+ if (type != null)
+ return type;
+
+ // Search all loaded assemblies
+ foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
+ {
+ type = assembly.GetType(typeNameOnly);
+ if (type != null)
+ return type;
+ }
+
+ return null;
+ }
+
+ private static MethodInfo? FindQueryableMethod(Type declaringType, string methodName, List? genericArgs, int argCount)
+ {
+ return declaringType.GetMethods()
+ .Where(m => m.Name == methodName)
+ .FirstOrDefault(m =>
+ {
+ var parameters = m.GetParameters();
+ if (parameters.Length != argCount) return false;
+
+ // Check if generic argument count matches
+ if (m.IsGenericMethodDefinition)
+ {
+ var genericCount = genericArgs?.Count ?? 1;
+ if (m.GetGenericArguments().Length != genericCount) return false;
+ }
+
+ return true;
+ });
+ }
+
+ #endregion
+
+ #region Expression Rebuilding
+
+ ///
+ /// Rebuilds an Expression from AcExpressionNode for the target Expression type.
+ /// Used by both JSON and Binary deserializers.
+ ///
+ /// The serialized expression node
+ /// The target Expression<TDelegate> type
+ /// The rebuilt Expression, or null if conversion fails
+ public static object? RebuildExpression(AcExpressionNode node, Type targetExpressionType)
+ {
+ if (node == null)
+ return null;
+
+ var entityType = GetExpressionEntityType(targetExpressionType);
+ return AcExpressionRebuilder.FromNode(node, entityType);
+ }
+
+ #endregion
+
+ #region Expression Compilation Helpers
+
+ ///
+ /// Creates a compiled getter for a property using expression trees.
+ /// Shared across all TypeMetadata implementations.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static Func
public static partial class AcBinarySerializer
{
@@ -39,6 +41,7 @@ public static partial class AcBinarySerializer
private static readonly Type DecimalType = typeof(decimal);
private static readonly Type BoolType = typeof(bool);
private static readonly Type DateTimeType = typeof(DateTime);
+ private static readonly Type ExpressionBaseType = typeof(Expression);
#region Public API
@@ -59,7 +62,22 @@ public static partial class AcBinarySerializer
}
var runtimeType = value.GetType();
- var context = SerializeCore(value, runtimeType, options);
+
+ // Handle IQueryable types - convert to AcExpressionNode (serialize the Expression)
+ object actualValue = value;
+ if (value is IQueryable queryable)
+ {
+ actualValue = AcSerializerCommon.QueryableToNode(queryable);
+ runtimeType = typeof(AcExpressionNode);
+ }
+ // Handle Expression types - convert to AcExpressionNode
+ else if (IsExpressionType(runtimeType))
+ {
+ actualValue = AcExpressionConverter.ToNode((Expression)(object)value);
+ runtimeType = typeof(AcExpressionNode);
+ }
+
+ var context = SerializeCore(actualValue, runtimeType, options);
try
{
return context.ToArray();
@@ -85,7 +103,22 @@ public static partial class AcBinarySerializer
}
var runtimeType = value.GetType();
- var context = SerializeCore(value, runtimeType, options);
+
+ // Handle IQueryable types - convert to AcExpressionNode (serialize the Expression)
+ object actualValue = value;
+ if (value is IQueryable queryable)
+ {
+ actualValue = AcSerializerCommon.QueryableToNode(queryable);
+ runtimeType = typeof(AcExpressionNode);
+ }
+ // Handle Expression types - convert to AcExpressionNode
+ else if (IsExpressionType(runtimeType))
+ {
+ actualValue = AcExpressionConverter.ToNode((Expression)(object)value);
+ runtimeType = typeof(AcExpressionNode);
+ }
+
+ var context = SerializeCore(actualValue, runtimeType, options);
try
{
context.WriteTo(writer);
@@ -96,6 +129,15 @@ public static partial class AcBinarySerializer
}
}
+ ///
+ /// Checks if a type is an Expression type.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static bool IsExpressionType(Type type)
+ {
+ return ExpressionBaseType.IsAssignableFrom(type);
+ }
+
///
/// Get the serialized size without allocating the final array.
/// Useful for pre-allocating buffers.
diff --git a/AyCode.Core/Serializers/Expressions/AcExpressionConverter.cs b/AyCode.Core/Serializers/Expressions/AcExpressionConverter.cs
new file mode 100644
index 0000000..35d9e05
--- /dev/null
+++ b/AyCode.Core/Serializers/Expressions/AcExpressionConverter.cs
@@ -0,0 +1,435 @@
+using System.Linq.Expressions;
+using System.Reflection;
+
+namespace AyCode.Core.Serializers.Expressions;
+
+///
+/// Converts Expression trees to AcExpressionNode DTO.
+/// Format-agnostic - produces a DTO that can be serialized with any serializer.
+///
+public class AcExpressionConverter : ExpressionVisitor
+{
+ private readonly Dictionary _parameterIndexes = new();
+ private int _nextParameterIndex;
+
+ // Stack to collect converted nodes
+ private readonly Stack _nodeStack = new();
+
+ ///
+ /// Converts an Expression to an AcExpressionNode DTO.
+ ///
+ public AcExpressionNode Convert(Expression expression)
+ {
+ _nodeStack.Clear();
+ _parameterIndexes.Clear();
+ _nextParameterIndex = 0;
+
+ VisitAndConvert(expression);
+
+ return _nodeStack.Count != 1
+ ? throw new InvalidOperationException($"Expected 1 node on stack, found {_nodeStack.Count}")
+ : _nodeStack.Pop();
+ }
+
+ ///
+ /// Static convenience method to convert an Expression to AcExpressionNode.
+ ///
+ public static AcExpressionNode ToNode(Expression expression)
+ {
+ var converter = new AcExpressionConverter();
+ return converter.Convert(expression);
+ }
+
+ ///
+ /// Static convenience method to convert a typed Expression to AcExpressionNode.
+ ///
+ public static AcExpressionNode ToNode(Expression> expression)
+ {
+ return ToNode((Expression)expression);
+ }
+
+ private void VisitAndConvert(Expression expression)
+ {
+ Visit(expression);
+ }
+
+ protected override Expression VisitBinary(BinaryExpression node)
+ {
+ VisitAndConvert(node.Left);
+ var left = _nodeStack.Pop();
+
+ VisitAndConvert(node.Right);
+ var right = _nodeStack.Pop();
+
+ _nodeStack.Push(new AcExpressionNode
+ {
+ NodeType = node.NodeType,
+ TypeName = node.Type.FullName,
+ Left = left,
+ Right = right
+ });
+
+ return node;
+ }
+
+ protected override Expression VisitUnary(UnaryExpression node)
+ {
+ VisitAndConvert(node.Operand);
+ var operand = _nodeStack.Pop();
+
+ _nodeStack.Push(new AcExpressionNode
+ {
+ NodeType = node.NodeType,
+ TypeName = node.Type.FullName,
+ Operand = operand
+ });
+
+ return node;
+ }
+
+ protected override Expression VisitLambda(Expression node)
+ {
+ // Register parameters with indexes
+ var parameters = new List();
+ foreach (var param in node.Parameters)
+ {
+ var index = _nextParameterIndex++;
+ _parameterIndexes[param] = index;
+ parameters.Add(new ParameterNode
+ {
+ Name = param.Name ?? $"p{index}",
+ TypeName = param.Type.FullName ?? param.Type.Name,
+ Index = index
+ });
+ }
+
+ VisitAndConvert(node.Body);
+ var body = _nodeStack.Pop();
+
+ _nodeStack.Push(new AcExpressionNode
+ {
+ NodeType = ExpressionType.Lambda,
+ TypeName = node.Type.FullName,
+ Body = body,
+ Parameters = parameters
+ });
+
+ return node;
+ }
+
+ protected override Expression VisitParameter(ParameterExpression node)
+ {
+ var index = _parameterIndexes.GetValueOrDefault(node, -1);
+
+ _nodeStack.Push(new AcExpressionNode
+ {
+ NodeType = ExpressionType.Parameter,
+ TypeName = node.Type.FullName,
+ ParameterName = node.Name,
+ ParameterIndex = index
+ });
+
+ return node;
+ }
+
+ protected override Expression VisitMember(MemberExpression node)
+ {
+ // Check if this is a closure variable access (captured variable)
+ if (IsClosureAccess(node))
+ {
+ var value = EvaluateClosureValue(node);
+ _nodeStack.Push(CreateConstantNode(value, node.Type));
+ return node;
+ }
+
+ AcExpressionNode? objectNode = null;
+ if (node.Expression != null)
+ {
+ VisitAndConvert(node.Expression);
+ objectNode = _nodeStack.Pop();
+ }
+
+ _nodeStack.Push(new AcExpressionNode
+ {
+ NodeType = ExpressionType.MemberAccess,
+ TypeName = node.Type.FullName,
+ MemberName = node.Member.Name,
+ Object = objectNode,
+ DeclaringType = node.Member.DeclaringType?.FullName
+ });
+
+ return node;
+ }
+
+ protected override Expression VisitConstant(ConstantExpression node)
+ {
+ _nodeStack.Push(CreateConstantNode(node.Value, node.Type));
+ return node;
+ }
+
+ ///
+ /// Creates a constant node with type-safe value storage.
+ ///
+ private static AcExpressionNode CreateConstantNode(object? value, Type type)
+ {
+ // IQueryable sources cannot be serialized
+ if (value is IQueryable)
+ {
+ return new AcExpressionNode
+ {
+ NodeType = ExpressionType.Constant,
+ TypeName = type.FullName,
+ ValueType = ConstantValueType.Null
+ };
+ }
+
+ var node = new AcExpressionNode
+ {
+ NodeType = ExpressionType.Constant,
+ TypeName = type.FullName
+ };
+
+ node.SetValue(value);
+ return node;
+ }
+
+ protected override Expression VisitMethodCall(MethodCallExpression node)
+ {
+ AcExpressionNode? objectNode = null;
+ if (node.Object != null)
+ {
+ VisitAndConvert(node.Object);
+ objectNode = _nodeStack.Pop();
+ }
+
+ var arguments = new List();
+ foreach (var arg in node.Arguments)
+ {
+ VisitAndConvert(arg);
+ arguments.Add(_nodeStack.Pop());
+ }
+
+ var genericArgs = node.Method.IsGenericMethod
+ ? node.Method.GetGenericArguments().Select(t => t.AssemblyQualifiedName ?? t.FullName ?? t.Name).ToList()
+ : null;
+
+ _nodeStack.Push(new AcExpressionNode
+ {
+ NodeType = ExpressionType.Call,
+ TypeName = node.Type.AssemblyQualifiedName ?? node.Type.FullName,
+ MethodName = node.Method.Name,
+ Object = objectNode,
+ Arguments = arguments,
+ // Use AssemblyQualifiedName for full type resolution (EF Core Include, linq2db LoadWith, etc.)
+ DeclaringType = node.Method.DeclaringType?.AssemblyQualifiedName ?? node.Method.DeclaringType?.FullName,
+ GenericArguments = genericArgs
+ });
+
+ return node;
+ }
+
+ protected override Expression VisitConditional(ConditionalExpression node)
+ {
+ VisitAndConvert(node.Test);
+ var test = _nodeStack.Pop();
+
+ VisitAndConvert(node.IfTrue);
+ var ifTrue = _nodeStack.Pop();
+
+ VisitAndConvert(node.IfFalse);
+ var ifFalse = _nodeStack.Pop();
+
+ _nodeStack.Push(new AcExpressionNode
+ {
+ NodeType = ExpressionType.Conditional,
+ TypeName = node.Type.FullName,
+ Test = test,
+ IfTrue = ifTrue,
+ IfFalse = ifFalse
+ });
+
+ return node;
+ }
+
+ protected override Expression VisitNew(NewExpression node)
+ {
+ var arguments = new List();
+ foreach (var arg in node.Arguments)
+ {
+ VisitAndConvert(arg);
+ arguments.Add(_nodeStack.Pop());
+ }
+
+ _nodeStack.Push(new AcExpressionNode
+ {
+ NodeType = ExpressionType.New,
+ TypeName = node.Type.FullName,
+ ConstructorArguments = arguments
+ });
+
+ return node;
+ }
+
+ protected override Expression VisitMemberInit(MemberInitExpression node)
+ {
+ var arguments = new List();
+ foreach (var arg in node.NewExpression.Arguments)
+ {
+ VisitAndConvert(arg);
+ arguments.Add(_nodeStack.Pop());
+ }
+
+ _nodeStack.Push(new AcExpressionNode
+ {
+ NodeType = ExpressionType.MemberInit,
+ TypeName = node.Type.FullName,
+ ConstructorArguments = arguments,
+ MemberBindings = node.Bindings.Select(ConvertMemberBinding).ToList()
+ });
+
+ return node;
+ }
+
+ protected override Expression VisitNewArray(NewArrayExpression node)
+ {
+ var elements = new List();
+ foreach (var expr in node.Expressions)
+ {
+ VisitAndConvert(expr);
+ elements.Add(_nodeStack.Pop());
+ }
+
+ _nodeStack.Push(new AcExpressionNode
+ {
+ NodeType = node.NodeType,
+ TypeName = node.Type.FullName,
+ Elements = elements
+ });
+
+ return node;
+ }
+
+ protected override Expression VisitTypeBinary(TypeBinaryExpression node)
+ {
+ VisitAndConvert(node.Expression);
+ var operand = _nodeStack.Pop();
+
+ _nodeStack.Push(new AcExpressionNode
+ {
+ NodeType = node.NodeType,
+ TypeName = node.TypeOperand.FullName,
+ Operand = operand
+ });
+
+ return node;
+ }
+
+ protected override Expression VisitInvocation(InvocationExpression node)
+ {
+ VisitAndConvert(node.Expression);
+ var objectNode = _nodeStack.Pop();
+
+ var arguments = new List();
+ foreach (var arg in node.Arguments)
+ {
+ VisitAndConvert(arg);
+ arguments.Add(_nodeStack.Pop());
+ }
+
+ _nodeStack.Push(new AcExpressionNode
+ {
+ NodeType = ExpressionType.Invoke,
+ TypeName = node.Type.FullName,
+ Object = objectNode,
+ Arguments = arguments
+ });
+
+ return node;
+ }
+
+ #region Helper Methods
+
+ private MemberBindingNode ConvertMemberBinding(MemberBinding binding)
+ {
+ return binding switch
+ {
+ MemberAssignment assignment => ConvertMemberAssignment(assignment),
+ MemberMemberBinding memberBinding => new MemberBindingNode
+ {
+ MemberName = memberBinding.Member.Name,
+ BindingType = MemberBindingType.MemberBinding,
+ Bindings = memberBinding.Bindings.Select(ConvertMemberBinding).ToList()
+ },
+ MemberListBinding listBinding => new MemberBindingNode
+ {
+ MemberName = listBinding.Member.Name,
+ BindingType = MemberBindingType.ListBinding,
+ Initializers = listBinding.Initializers
+ .Select(i => i.Arguments.Select(ConvertArgument).ToList())
+ .ToList()
+ },
+ _ => throw new NotSupportedException($"Member binding type '{binding.BindingType}' is not supported.")
+ };
+ }
+
+ private MemberBindingNode ConvertMemberAssignment(MemberAssignment assignment)
+ {
+ VisitAndConvert(assignment.Expression);
+ var expr = _nodeStack.Pop();
+
+ return new MemberBindingNode
+ {
+ MemberName = assignment.Member.Name,
+ BindingType = MemberBindingType.Assignment,
+ Expression = expr
+ };
+ }
+
+ private AcExpressionNode ConvertArgument(Expression expression)
+ {
+ VisitAndConvert(expression);
+ return _nodeStack.Pop();
+ }
+
+ private static bool IsClosureAccess(MemberExpression node)
+ {
+ return node.Expression switch
+ {
+ ConstantExpression => true,
+ MemberExpression nested => IsClosureAccess(nested),
+ _ => false
+ };
+ }
+
+ private static object? EvaluateClosureValue(MemberExpression node)
+ {
+ var objectStack = new Stack();
+ Expression? current = node;
+
+ while (current is MemberExpression me)
+ {
+ objectStack.Push(me);
+ current = me.Expression;
+ }
+
+ if (current is not ConstantExpression constant)
+ throw new InvalidOperationException("Expected constant at root of closure access.");
+
+ object? value = constant.Value;
+
+ while (objectStack.Count > 0)
+ {
+ var me = objectStack.Pop();
+ value = me.Member switch
+ {
+ FieldInfo fi => fi.GetValue(value),
+ PropertyInfo pi => pi.GetValue(value),
+ _ => throw new InvalidOperationException($"Unsupported member type: {me.Member.GetType()}")
+ };
+ }
+
+ return value;
+ }
+
+ #endregion
+}
diff --git a/AyCode.Core/Serializers/Expressions/AcExpressionHelper.cs b/AyCode.Core/Serializers/Expressions/AcExpressionHelper.cs
new file mode 100644
index 0000000..c70ae5f
--- /dev/null
+++ b/AyCode.Core/Serializers/Expressions/AcExpressionHelper.cs
@@ -0,0 +1,185 @@
+using System.Linq.Expressions;
+
+namespace AyCode.Core.Serializers.Expressions;
+
+///
+/// Helper class for converting Expression trees to/from AcExpressionNode DTO.
+/// Format-agnostic - use AcJsonSerializer or AcBinarySerializer to serialize the nodes.
+///
+public static class AcExpressionHelper
+{
+ #region Expression Conversion
+
+ ///
+ /// Converts an Expression to AcExpressionNode DTO.
+ ///
+ public static AcExpressionNode ToNode(Expression expression)
+ {
+ return AcExpressionConverter.ToNode(expression);
+ }
+
+ ///
+ /// Converts a typed Expression to AcExpressionNode DTO.
+ ///
+ public static AcExpressionNode ToNode(Expression> expression)
+ {
+ return AcExpressionConverter.ToNode(expression);
+ }
+
+ ///
+ /// Rebuilds an Expression from AcExpressionNode DTO.
+ ///
+ public static Expression FromNode(AcExpressionNode node, Type? entityType = null)
+ {
+ return AcExpressionRebuilder.FromNode(node, entityType);
+ }
+
+ ///
+ /// Rebuilds a typed Expression from AcExpressionNode DTO.
+ ///
+ public static Expression> FromNode(AcExpressionNode node)
+ {
+ return AcExpressionRebuilder.FromNode(node);
+ }
+
+ #endregion
+
+ #region IQueryable Conversion
+
+ ///
+ /// Converts an IQueryable's expression tree to AcExpressionNode.
+ ///
+ public static AcExpressionNode QueryToNode(IQueryable query)
+ {
+ return ToNode(query.Expression);
+ }
+
+ ///
+ /// Applies an AcExpressionNode query to an IQueryable source.
+ ///
+ public static IQueryable ApplyQueryFromNode(IQueryable source, AcExpressionNode node)
+ {
+ var expression = RebuildQueryExpression(source.Expression, node, typeof(T));
+ return source.Provider.CreateQuery(expression);
+ }
+
+ #endregion
+
+ #region Private Helpers
+
+ ///
+ /// Rebuilds a query expression, replacing the source with the provided expression.
+ ///
+ private static Expression RebuildQueryExpression(Expression sourceExpression, AcExpressionNode node, Type entityType)
+ {
+ if (node is { NodeType: ExpressionType.Call, MethodName: not null })
+ {
+ return RebuildMethodCallChain(sourceExpression, node, entityType);
+ }
+
+ // If it's just a lambda (filter expression), wrap it in a Where call
+ if (node.NodeType == ExpressionType.Lambda)
+ {
+ var rebuilder = new AcExpressionRebuilder();
+ var lambda = rebuilder.Rebuild(node, entityType);
+
+ var whereMethod = typeof(Queryable).GetMethods()
+ .First(m => m.Name == "Where" && m.GetParameters().Length == 2)
+ .MakeGenericMethod(entityType);
+
+ return Expression.Call(whereMethod, sourceExpression, lambda);
+ }
+
+ throw new NotSupportedException($"Cannot apply expression of type '{node.NodeType}' to IQueryable.");
+ }
+
+ ///
+ /// Rebuilds a chain of method calls (Where, OrderBy, Skip, Take, etc.)
+ ///
+ private static Expression RebuildMethodCallChain(Expression sourceExpression, AcExpressionNode node, Type entityType)
+ {
+ // First, process the inner expression (the source of this method call)
+ Expression currentSource;
+
+ if (node.Arguments?.Count > 0 && node.Arguments[0].NodeType == ExpressionType.Call)
+ {
+ // Recursive: rebuild the inner chain first
+ currentSource = RebuildMethodCallChain(sourceExpression, node.Arguments[0], entityType);
+ }
+ else
+ {
+ // Base case: use the provided source
+ currentSource = sourceExpression;
+ }
+
+ // Now apply this method call
+ var methodName = node.MethodName!;
+ var declaringType = node.DeclaringType != null ? Type.GetType(node.DeclaringType) : typeof(Queryable);
+
+ // Find the method
+ var method = FindQueryableMethod(declaringType!, methodName, node.GenericArguments, node.Arguments?.Count ?? 1);
+
+ if (method == null)
+ throw new InvalidOperationException($"Method '{methodName}' not found.");
+
+ // Apply generic type arguments
+ if (method.IsGenericMethodDefinition && node.GenericArguments?.Count > 0)
+ {
+ var genericTypes = node.GenericArguments.Select(t => Type.GetType(t) ?? entityType).ToArray();
+ method = method.MakeGenericMethod(genericTypes);
+ }
+
+ // Build arguments
+ var rebuilder = new AcExpressionRebuilder();
+ var arguments = new List { currentSource };
+
+ // Skip first argument (it's the source) and rebuild the rest
+ if (node.Arguments?.Count > 1)
+ {
+ for (var i = 1; i < node.Arguments.Count; i++)
+ {
+ var argNode = node.Arguments[i];
+
+ if (argNode is { NodeType: ExpressionType.Quote, Operand: not null })
+ {
+ // Quoted lambda - unquote and rebuild
+ var lambda = rebuilder.Rebuild(argNode.Operand, entityType);
+ arguments.Add(Expression.Quote(lambda));
+ }
+ else if (argNode.NodeType == ExpressionType.Lambda)
+ {
+ var lambda = rebuilder.Rebuild(argNode, entityType);
+ arguments.Add(Expression.Quote(lambda));
+ }
+ else
+ {
+ arguments.Add(rebuilder.Rebuild(argNode, entityType));
+ }
+ }
+ }
+
+ return Expression.Call(method, arguments);
+ }
+
+ private static System.Reflection.MethodInfo? FindQueryableMethod(Type declaringType, string methodName, List? genericArgs, int argCount)
+ {
+ return declaringType.GetMethods()
+ .Where(m => m.Name == methodName)
+ .FirstOrDefault(m =>
+ {
+ var parameters = m.GetParameters();
+ if (parameters.Length != argCount) return false;
+
+ // Check if generic argument count matches
+ if (m.IsGenericMethodDefinition)
+ {
+ var genericCount = genericArgs?.Count ?? 1;
+ if (m.GetGenericArguments().Length != genericCount) return false;
+ }
+
+ return true;
+ });
+ }
+
+ #endregion
+}
diff --git a/AyCode.Core/Serializers/Expressions/AcExpressionNode.cs b/AyCode.Core/Serializers/Expressions/AcExpressionNode.cs
new file mode 100644
index 0000000..6f0c3ae
--- /dev/null
+++ b/AyCode.Core/Serializers/Expressions/AcExpressionNode.cs
@@ -0,0 +1,334 @@
+using System.Linq.Expressions;
+
+namespace AyCode.Core.Serializers.Expressions;
+
+///
+/// Constant value type for serialization.
+/// Determines which typed property contains the actual value.
+///
+public enum ConstantValueType : byte
+{
+ Null = 0,
+ Int32 = 1,
+ Int64 = 2,
+ Double = 3,
+ Decimal = 4,
+ Boolean = 5,
+ String = 6,
+ Guid = 7,
+ DateTime = 8,
+ DateTimeOffset = 9,
+ TimeSpan = 10,
+ Char = 11,
+ Byte = 12,
+ Int16 = 13,
+ Single = 14,
+ UInt32 = 15,
+ UInt64 = 16,
+ UInt16 = 17,
+ SByte = 18,
+ DateOnly = 19,
+ TimeOnly = 20,
+ Enum = 50,
+}
+
+///
+/// Universal DTO representing any Expression node.
+/// Recursively represents the entire Expression tree.
+/// Format-agnostic - can be serialized with any serializer (JSON, Binary, etc.)
+///
+public sealed class AcExpressionNode
+{
+ ///
+ /// The expression node type (Add, Equal, Call, MemberAccess, Lambda, etc.)
+ ///
+ public ExpressionType NodeType { get; set; }
+
+ ///
+ /// The CLR type name of this expression's result.
+ ///
+ public string? TypeName { get; set; }
+
+ #region Binary Expressions (Add, Equal, AndAlso, OrElse, etc.)
+
+ public AcExpressionNode? Left { get; set; }
+ public AcExpressionNode? Right { get; set; }
+
+ #endregion
+
+ #region Unary Expressions (Not, Convert, Negate, etc.)
+
+ public AcExpressionNode? Operand { get; set; }
+
+ #endregion
+
+ #region Lambda Expressions
+
+ public AcExpressionNode? Body { get; set; }
+ public List? Parameters { get; set; }
+
+ #endregion
+
+ #region Member Access
+
+ public string? MemberName { get; set; }
+ public AcExpressionNode? Object { get; set; }
+ public string? DeclaringType { get; set; }
+
+ #endregion
+
+ #region Method Call
+
+ public string? MethodName { get; set; }
+ public List? Arguments { get; set; }
+ public List? GenericArguments { get; set; }
+
+ #endregion
+
+ #region Constant Values - Type-safe storage
+
+ public ConstantValueType ValueType { get; set; }
+ public int ValueInt32 { get; set; }
+ public long ValueInt64 { get; set; }
+ public double ValueDouble { get; set; }
+ public decimal ValueDecimal { get; set; }
+ public bool ValueBool { get; set; }
+ public string? ValueString { get; set; }
+ public Guid ValueGuid { get; set; }
+ public DateTime ValueDateTime { get; set; }
+ public DateTimeOffset ValueDateTimeOffset { get; set; }
+ public TimeSpan ValueTimeSpan { get; set; }
+
+ ///
+ /// For enum values, stores the assembly-qualified enum type name.
+ ///
+ public string? EnumTypeName { get; set; }
+
+ #endregion
+
+ #region Parameter
+
+ public string? ParameterName { get; set; }
+ public int? ParameterIndex { get; set; }
+
+ #endregion
+
+ #region Conditional (Ternary)
+
+ public AcExpressionNode? Test { get; set; }
+ public AcExpressionNode? IfTrue { get; set; }
+ public AcExpressionNode? IfFalse { get; set; }
+
+ #endregion
+
+ #region New Expression
+
+ public List? ConstructorArguments { get; set; }
+ public List? MemberBindings { get; set; }
+
+ #endregion
+
+ #region Array/Collection
+
+ public List? Elements { get; set; }
+
+ #endregion
+
+ #region Value Helpers
+
+ ///
+ /// Gets the constant value based on ValueType.
+ ///
+ public object? GetValue()
+ {
+ return ValueType switch
+ {
+ ConstantValueType.Null => null,
+ ConstantValueType.Int32 => ValueInt32,
+ ConstantValueType.Int64 => ValueInt64,
+ ConstantValueType.Double => ValueDouble,
+ ConstantValueType.Decimal => ValueDecimal,
+ ConstantValueType.Boolean => ValueBool,
+ ConstantValueType.String => ValueString,
+ ConstantValueType.Guid => ValueGuid,
+ ConstantValueType.DateTime => ValueDateTime,
+ ConstantValueType.DateTimeOffset => ValueDateTimeOffset,
+ ConstantValueType.TimeSpan => ValueTimeSpan,
+ ConstantValueType.Char => (char)ValueInt32,
+ ConstantValueType.Byte => (byte)ValueInt32,
+ ConstantValueType.Int16 => (short)ValueInt32,
+ ConstantValueType.Single => (float)ValueDouble,
+ ConstantValueType.UInt32 => (uint)ValueInt64,
+ ConstantValueType.UInt64 => (ulong)ValueInt64,
+ ConstantValueType.UInt16 => (ushort)ValueInt32,
+ ConstantValueType.SByte => (sbyte)ValueInt32,
+ ConstantValueType.DateOnly => DateOnly.FromDateTime(ValueDateTime),
+ ConstantValueType.TimeOnly => TimeOnly.FromTimeSpan(ValueTimeSpan),
+ ConstantValueType.Enum => GetEnumValue(),
+ _ => null
+ };
+ }
+
+ private object GetEnumValue()
+ {
+ if (string.IsNullOrEmpty(EnumTypeName))
+ return ValueInt32;
+
+ var enumType = Type.GetType(EnumTypeName);
+ if (enumType == null)
+ {
+ // Fallback: try all loaded assemblies
+ foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
+ {
+ enumType = assembly.GetType(EnumTypeName.Split(',')[0].Trim());
+ if (enumType != null) break;
+ }
+ }
+
+ return enumType != null ? Enum.ToObject(enumType, ValueInt32) : ValueInt32;
+ }
+
+ ///
+ /// Sets the constant value with automatic type detection.
+ ///
+ public void SetValue(object? value)
+ {
+ if (value == null)
+ {
+ ValueType = ConstantValueType.Null;
+ return;
+ }
+
+ var type = value.GetType();
+ var underlyingType = Nullable.GetUnderlyingType(type) ?? type;
+
+ if (underlyingType.IsEnum)
+ {
+ ValueType = ConstantValueType.Enum;
+ ValueInt32 = Convert.ToInt32(value);
+ // Store assembly-qualified name for cross-assembly resolution
+ EnumTypeName = underlyingType.AssemblyQualifiedName;
+ return;
+ }
+
+ switch (Type.GetTypeCode(underlyingType))
+ {
+ case TypeCode.Int32:
+ ValueType = ConstantValueType.Int32;
+ ValueInt32 = (int)value;
+ break;
+ case TypeCode.Int64:
+ ValueType = ConstantValueType.Int64;
+ ValueInt64 = (long)value;
+ break;
+ case TypeCode.Double:
+ ValueType = ConstantValueType.Double;
+ ValueDouble = (double)value;
+ break;
+ case TypeCode.Decimal:
+ ValueType = ConstantValueType.Decimal;
+ ValueDecimal = (decimal)value;
+ break;
+ case TypeCode.Boolean:
+ ValueType = ConstantValueType.Boolean;
+ ValueBool = (bool)value;
+ break;
+ case TypeCode.String:
+ ValueType = ConstantValueType.String;
+ ValueString = (string)value;
+ break;
+ case TypeCode.DateTime:
+ ValueType = ConstantValueType.DateTime;
+ ValueDateTime = (DateTime)value;
+ break;
+ case TypeCode.Char:
+ ValueType = ConstantValueType.Char;
+ ValueInt32 = (char)value;
+ break;
+ case TypeCode.Byte:
+ ValueType = ConstantValueType.Byte;
+ ValueInt32 = (byte)value;
+ break;
+ case TypeCode.Int16:
+ ValueType = ConstantValueType.Int16;
+ ValueInt32 = (short)value;
+ break;
+ case TypeCode.Single:
+ ValueType = ConstantValueType.Single;
+ ValueDouble = (float)value;
+ break;
+ case TypeCode.UInt32:
+ ValueType = ConstantValueType.UInt32;
+ ValueInt64 = (uint)value;
+ break;
+ case TypeCode.UInt64:
+ ValueType = ConstantValueType.UInt64;
+ ValueInt64 = (long)(ulong)value;
+ break;
+ case TypeCode.UInt16:
+ ValueType = ConstantValueType.UInt16;
+ ValueInt32 = (ushort)value;
+ break;
+ case TypeCode.SByte:
+ ValueType = ConstantValueType.SByte;
+ ValueInt32 = (sbyte)value;
+ break;
+ default:
+ if (underlyingType == typeof(Guid))
+ {
+ ValueType = ConstantValueType.Guid;
+ ValueGuid = (Guid)value;
+ }
+ else if (underlyingType == typeof(DateTimeOffset))
+ {
+ ValueType = ConstantValueType.DateTimeOffset;
+ ValueDateTimeOffset = (DateTimeOffset)value;
+ }
+ else if (underlyingType == typeof(TimeSpan))
+ {
+ ValueType = ConstantValueType.TimeSpan;
+ ValueTimeSpan = (TimeSpan)value;
+ }
+ else if (underlyingType == typeof(DateOnly))
+ {
+ ValueType = ConstantValueType.DateOnly;
+ ValueDateTime = ((DateOnly)value).ToDateTime(TimeOnly.MinValue);
+ }
+ else if (underlyingType == typeof(TimeOnly))
+ {
+ ValueType = ConstantValueType.TimeOnly;
+ ValueTimeSpan = ((TimeOnly)value).ToTimeSpan();
+ }
+ else
+ {
+ throw new NotSupportedException(
+ $"Constant value type '{type.FullName}' is not supported for Expression serialization.");
+ }
+ break;
+ }
+ }
+
+ #endregion
+}
+
+///
+/// Represents a parameter definition in a lambda expression.
+///
+public sealed class ParameterNode
+{
+ public string Name { get; set; } = "";
+ public string TypeName { get; set; } = "";
+ public int Index { get; set; }
+}
+
+///
+/// Represents a member binding in MemberInit expressions.
+///
+public sealed class MemberBindingNode
+{
+ public string MemberName { get; set; } = "";
+ public MemberBindingType BindingType { get; set; }
+ public AcExpressionNode? Expression { get; set; }
+ public List? Bindings { get; set; }
+ public List>? Initializers { get; set; }
+}
diff --git a/AyCode.Core/Serializers/Expressions/AcExpressionRebuilder.cs b/AyCode.Core/Serializers/Expressions/AcExpressionRebuilder.cs
new file mode 100644
index 0000000..290e80d
--- /dev/null
+++ b/AyCode.Core/Serializers/Expressions/AcExpressionRebuilder.cs
@@ -0,0 +1,360 @@
+using System.Linq.Expressions;
+using System.Reflection;
+
+namespace AyCode.Core.Serializers.Expressions;
+
+///
+/// Rebuilds Expression trees from AcExpressionNode DTO.
+/// Format-agnostic - works with any deserialized AcExpressionNode.
+///
+public class AcExpressionRebuilder
+{
+ private readonly Dictionary _parameters = new();
+
+ ///
+ /// Rebuilds an Expression from AcExpressionNode.
+ ///
+ public Expression Rebuild(AcExpressionNode node, Type? entityType = null)
+ {
+ return node.NodeType switch
+ {
+ ExpressionType.Lambda => RebuildLambda(node, entityType),
+ ExpressionType.Parameter => RebuildParameter(node),
+ ExpressionType.Constant => RebuildConstant(node),
+ ExpressionType.MemberAccess => RebuildMemberAccess(node, entityType),
+ ExpressionType.Call => RebuildMethodCall(node, entityType),
+ ExpressionType.Conditional => RebuildConditional(node, entityType),
+ ExpressionType.New => RebuildNew(node, entityType),
+ ExpressionType.MemberInit => RebuildMemberInit(node, entityType),
+ ExpressionType.NewArrayInit or ExpressionType.NewArrayBounds => RebuildNewArray(node, entityType),
+ ExpressionType.Invoke => RebuildInvocation(node, entityType),
+ ExpressionType.TypeIs or ExpressionType.TypeAs => RebuildTypeBinary(node, entityType),
+
+ // Unary expressions
+ ExpressionType.Not or ExpressionType.Negate or ExpressionType.NegateChecked or
+ ExpressionType.Convert or ExpressionType.ConvertChecked or ExpressionType.ArrayLength or
+ ExpressionType.Quote or ExpressionType.UnaryPlus
+ => RebuildUnary(node, entityType),
+
+ // Binary expressions
+ ExpressionType.Add or ExpressionType.AddChecked or ExpressionType.Subtract or
+ ExpressionType.SubtractChecked or ExpressionType.Multiply or ExpressionType.MultiplyChecked or
+ ExpressionType.Divide or ExpressionType.Modulo or ExpressionType.Power or
+ ExpressionType.And or ExpressionType.AndAlso or ExpressionType.Or or ExpressionType.OrElse or
+ ExpressionType.ExclusiveOr or ExpressionType.Equal or ExpressionType.NotEqual or
+ ExpressionType.LessThan or ExpressionType.LessThanOrEqual or
+ ExpressionType.GreaterThan or ExpressionType.GreaterThanOrEqual or
+ ExpressionType.Coalesce or ExpressionType.ArrayIndex or
+ ExpressionType.LeftShift or ExpressionType.RightShift
+ => RebuildBinary(node, entityType),
+
+ _ => throw new NotSupportedException($"Expression type '{node.NodeType}' is not supported.")
+ };
+ }
+
+ ///
+ /// Static convenience method to rebuild an Expression from AcExpressionNode.
+ ///
+ public static Expression FromNode(AcExpressionNode node, Type? entityType = null)
+ {
+ var rebuilder = new AcExpressionRebuilder();
+ return rebuilder.Rebuild(node, entityType);
+ }
+
+ ///
+ /// Static convenience method to rebuild a typed Expression from AcExpressionNode.
+ ///
+ public static Expression> FromNode(AcExpressionNode node)
+ {
+ var expression = FromNode(node, typeof(TEntity));
+ return (Expression>)expression;
+ }
+
+ #region Rebuild Methods
+
+ private LambdaExpression RebuildLambda(AcExpressionNode node, Type? entityType)
+ {
+ // Create parameters
+ var parameters = new List();
+ if (node.Parameters != null)
+ {
+ foreach (var paramNode in node.Parameters)
+ {
+ var paramType = entityType ?? ResolveType(paramNode.TypeName);
+ var param = Expression.Parameter(paramType, paramNode.Name);
+ _parameters[paramNode.Index] = param;
+ parameters.Add(param);
+
+ // Use entityType only for first parameter
+ entityType = null;
+ }
+ }
+
+ var body = Rebuild(node.Body!, null);
+ return Expression.Lambda(body, parameters);
+ }
+
+ private ParameterExpression RebuildParameter(AcExpressionNode node)
+ {
+ // Try by index first
+ if (node.ParameterIndex.HasValue && _parameters.TryGetValue(node.ParameterIndex.Value, out var param))
+ return param;
+
+ // Fallback: try by name (for cases where Index was not serialized, e.g., Index=0 skipped as default)
+ if (!string.IsNullOrEmpty(node.ParameterName))
+ {
+ var byName = _parameters.Values.FirstOrDefault(p => p.Name == node.ParameterName);
+ if (byName != null)
+ return byName;
+ }
+
+ // Last resort: if there's only one parameter, use it
+ if (_parameters.Count == 1)
+ return _parameters.Values.First();
+
+ throw new InvalidOperationException($"Parameter '{node.ParameterName}' not found. ParameterIndex: {node.ParameterIndex}");
+ }
+
+ private static ConstantExpression RebuildConstant(AcExpressionNode node)
+ {
+ var type = ResolveType(node.TypeName ?? "System.Object");
+
+ // Use the type-safe GetValue method
+ var value = node.GetValue();
+
+ if (value == null)
+ return Expression.Constant(null, type);
+
+ return Expression.Constant(value, type);
+ }
+
+ private Expression RebuildMemberAccess(AcExpressionNode node, Type? entityType)
+ {
+ if (node.Object == null)
+ {
+ // Static member access
+ var declaringType = ResolveType(node.DeclaringType!);
+ var member = declaringType.GetMember(node.MemberName!, BindingFlags.Public | BindingFlags.Static).FirstOrDefault()
+ ?? throw new InvalidOperationException($"Static member '{node.MemberName}' not found on type '{declaringType.Name}'.");
+ return Expression.MakeMemberAccess(null, member);
+ }
+
+ var obj = Rebuild(node.Object, entityType);
+ var memberInfo = obj.Type.GetMember(node.MemberName!, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase).FirstOrDefault()
+ ?? throw new InvalidOperationException($"Member '{node.MemberName}' not found on type '{obj.Type.Name}'.");
+
+ return Expression.MakeMemberAccess(obj, memberInfo);
+ }
+
+ private Expression RebuildMethodCall(AcExpressionNode node, Type? entityType)
+ {
+ var arguments = node.Arguments?.Select(a => Rebuild(a, entityType)).ToArray() ?? [];
+ var argumentTypes = arguments.Select(a => a.Type).ToArray();
+
+ var declaringType = node.DeclaringType != null ? ResolveType(node.DeclaringType) : null;
+ var instance = node.Object != null ? Rebuild(node.Object, entityType) : null;
+
+ MethodInfo? method = null;
+
+ if (instance != null)
+ {
+ // Instance method
+ method = FindMethod(instance.Type, node.MethodName!, argumentTypes, isStatic: false);
+ }
+ else if (declaringType != null)
+ {
+ // Static method (including extension methods)
+ method = FindMethod(declaringType, node.MethodName!, argumentTypes, isStatic: true);
+ }
+
+ if (method == null)
+ throw new InvalidOperationException($"Method '{node.MethodName}' not found.");
+
+ // Handle generic methods
+ if (method.IsGenericMethodDefinition && node.GenericArguments?.Count > 0)
+ {
+ var genericTypes = node.GenericArguments.Select(ResolveType).ToArray();
+ method = method.MakeGenericMethod(genericTypes);
+ }
+
+ return instance != null
+ ? Expression.Call(instance, method, arguments)
+ : Expression.Call(method, arguments);
+ }
+
+ private Expression RebuildBinary(AcExpressionNode node, Type? entityType)
+ {
+ var left = Rebuild(node.Left!, entityType);
+ var right = Rebuild(node.Right!, entityType);
+
+ // Handle type mismatches (e.g., nullable comparisons)
+ if (left.Type != right.Type)
+ {
+ if (Nullable.GetUnderlyingType(left.Type) == right.Type)
+ right = Expression.Convert(right, left.Type);
+ else if (Nullable.GetUnderlyingType(right.Type) == left.Type)
+ left = Expression.Convert(left, right.Type);
+ }
+
+ return Expression.MakeBinary(node.NodeType, left, right);
+ }
+
+ private Expression RebuildUnary(AcExpressionNode node, Type? entityType)
+ {
+ var operand = Rebuild(node.Operand!, entityType);
+ var type = node.TypeName != null ? ResolveType(node.TypeName) : null;
+
+ return node.NodeType switch
+ {
+ ExpressionType.Convert or ExpressionType.ConvertChecked when type != null
+ => Expression.Convert(operand, type),
+ _ => Expression.MakeUnary(node.NodeType, operand, type)
+ };
+ }
+
+ private Expression RebuildConditional(AcExpressionNode node, Type? entityType)
+ {
+ var test = Rebuild(node.Test!, entityType);
+ var ifTrue = Rebuild(node.IfTrue!, entityType);
+ var ifFalse = Rebuild(node.IfFalse!, entityType);
+ return Expression.Condition(test, ifTrue, ifFalse);
+ }
+
+ private Expression RebuildNew(AcExpressionNode node, Type? entityType)
+ {
+ var type = ResolveType(node.TypeName!);
+ var args = node.ConstructorArguments?.Select(a => Rebuild(a, entityType)).ToArray() ?? [];
+ var argTypes = args.Select(a => a.Type).ToArray();
+ var ctor = type.GetConstructor(argTypes)
+ ?? throw new InvalidOperationException($"Constructor not found for type '{type.Name}'.");
+ return Expression.New(ctor, args);
+ }
+
+ private Expression RebuildMemberInit(AcExpressionNode node, Type? entityType)
+ {
+ var type = ResolveType(node.TypeName!);
+ var args = node.ConstructorArguments?.Select(a => Rebuild(a, entityType)).ToArray() ?? [];
+ var argTypes = args.Select(a => a.Type).ToArray();
+ var ctor = type.GetConstructor(argTypes) ?? type.GetConstructor(Type.EmptyTypes)
+ ?? throw new InvalidOperationException($"Constructor not found for type '{type.Name}'.");
+
+ var newExpr = Expression.New(ctor, args);
+ var bindings = node.MemberBindings?.Select(b => RebuildMemberBinding(b, type, entityType)).ToList()
+ ?? [];
+
+ return Expression.MemberInit(newExpr, bindings);
+ }
+
+ private MemberBinding RebuildMemberBinding(MemberBindingNode node, Type declaringType, Type? entityType)
+ {
+ var member = declaringType.GetMember(node.MemberName, BindingFlags.Public | BindingFlags.Instance).FirstOrDefault()
+ ?? throw new InvalidOperationException($"Member '{node.MemberName}' not found on type '{declaringType.Name}'.");
+
+ return node.BindingType switch
+ {
+ MemberBindingType.Assignment => Expression.Bind(member, Rebuild(node.Expression!, entityType)),
+ MemberBindingType.MemberBinding => Expression.MemberBind(member,
+ node.Bindings?.Select(b => RebuildMemberBinding(b, GetMemberType(member), entityType)) ?? []),
+ MemberBindingType.ListBinding => Expression.ListBind(member,
+ node.Initializers?.Select(args => Expression.ElementInit(
+ GetAddMethod(GetMemberType(member)),
+ args.Select(a => Rebuild(a, entityType)))) ?? []),
+ _ => throw new NotSupportedException($"Binding type '{node.BindingType}' is not supported.")
+ };
+ }
+
+ private Expression RebuildNewArray(AcExpressionNode node, Type? entityType)
+ {
+ var elementType = ResolveType(node.TypeName!).GetElementType()
+ ?? throw new InvalidOperationException("Cannot determine array element type.");
+ var elements = node.Elements?.Select(e => Rebuild(e, entityType)).ToArray() ?? [];
+ return Expression.NewArrayInit(elementType, elements);
+ }
+
+ private Expression RebuildInvocation(AcExpressionNode node, Type? entityType)
+ {
+ var expression = Rebuild(node.Object!, entityType);
+ var arguments = node.Arguments?.Select(a => Rebuild(a, entityType)).ToArray() ?? [];
+ return Expression.Invoke(expression, arguments);
+ }
+
+ private Expression RebuildTypeBinary(AcExpressionNode node, Type? entityType)
+ {
+ var expression = Rebuild(node.Operand!, entityType);
+ var type = ResolveType(node.TypeName!);
+ return node.NodeType == ExpressionType.TypeIs
+ ? Expression.TypeIs(expression, type)
+ : Expression.TypeAs(expression, type);
+ }
+
+ #endregion
+
+ #region Helper Methods
+
+ private static MethodInfo? FindMethod(Type type, string methodName, Type[] argumentTypes, bool isStatic)
+ {
+ var bindingFlags = BindingFlags.Public | (isStatic ? BindingFlags.Static : BindingFlags.Instance);
+
+ // Try exact match first
+ var method = type.GetMethod(methodName, bindingFlags, null, argumentTypes, null);
+ if (method != null) return method;
+
+ // Try finding by name and parameter count
+ var candidates = type.GetMethods(bindingFlags)
+ .Where(m => m.Name == methodName && m.GetParameters().Length == argumentTypes.Length)
+ .ToList();
+
+ return candidates.FirstOrDefault();
+ }
+
+ private static Type GetMemberType(MemberInfo member) => member switch
+ {
+ PropertyInfo pi => pi.PropertyType,
+ FieldInfo fi => fi.FieldType,
+ _ => throw new InvalidOperationException($"Cannot get type for member '{member.Name}'.")
+ };
+
+ private static MethodInfo GetAddMethod(Type collectionType)
+ {
+ return collectionType.GetMethod("Add")
+ ?? throw new InvalidOperationException($"Add method not found on type '{collectionType.Name}'.");
+ }
+
+ private static Type ResolveType(string typeName)
+ {
+ var type = typeName switch
+ {
+ "System.String" or "string" => typeof(string),
+ "System.Int32" or "int" => typeof(int),
+ "System.Int64" or "long" => typeof(long),
+ "System.Int16" or "short" => typeof(short),
+ "System.Byte" or "byte" => typeof(byte),
+ "System.Boolean" or "bool" => typeof(bool),
+ "System.Double" or "double" => typeof(double),
+ "System.Single" or "float" => typeof(float),
+ "System.Decimal" or "decimal" => typeof(decimal),
+ "System.DateTime" => typeof(DateTime),
+ "System.DateTimeOffset" => typeof(DateTimeOffset),
+ "System.DateOnly" => typeof(DateOnly),
+ "System.TimeOnly" => typeof(TimeOnly),
+ "System.TimeSpan" => typeof(TimeSpan),
+ "System.Guid" => typeof(Guid),
+ "System.Object" or "object" => typeof(object),
+ _ => Type.GetType(typeName)
+ };
+
+ if (type == null && typeName.Contains("Nullable"))
+ {
+ var match = System.Text.RegularExpressions.Regex.Match(typeName, @"System\.Nullable`1\[\[(.+?),");
+ if (match.Success)
+ {
+ var underlyingType = ResolveType(match.Groups[1].Value);
+ type = typeof(Nullable<>).MakeGenericType(underlyingType);
+ }
+ }
+
+ return type ?? throw new InvalidOperationException($"Cannot resolve type '{typeName}'.");
+ }
+
+ #endregion
+}
diff --git a/AyCode.Core/Serializers/Jsons/AcJsonDeserializer.DeserializationContext.cs b/AyCode.Core/Serializers/Jsons/AcJsonDeserializer.DeserializationContext.cs
new file mode 100644
index 0000000..8f19fdf
--- /dev/null
+++ b/AyCode.Core/Serializers/Jsons/AcJsonDeserializer.DeserializationContext.cs
@@ -0,0 +1,109 @@
+using System.Collections.Concurrent;
+using System.Runtime.CompilerServices;
+
+namespace AyCode.Core.Serializers.Jsons;
+
+public static partial class AcJsonDeserializer
+{
+ private static class DeserializationContextPool
+ {
+ private static readonly ConcurrentQueue Pool = new();
+ private const int MaxPoolSize = 16;
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static DeserializationContext Get(in AcJsonSerializerOptions options)
+ {
+ if (Pool.TryDequeue(out var context))
+ {
+ context.Reset(options);
+ return context;
+ }
+ return new DeserializationContext(options);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static void Return(DeserializationContext context)
+ {
+ if (Pool.Count < MaxPoolSize)
+ {
+ context.Clear();
+ Pool.Enqueue(context);
+ }
+ }
+ }
+
+ private sealed class DeferredReference(string refId, Type targetType)
+ {
+ public string RefId { get; } = refId;
+ public Type TargetType { get; } = targetType;
+ }
+
+ private readonly struct PropertyToResolve(object target, PropertySetterInfo property, string refId)
+ {
+ public readonly object Target = target;
+ public readonly PropertySetterInfo Property = property;
+ public readonly string RefId = refId;
+ }
+
+ private sealed class DeserializationContext
+ {
+ private Dictionary? _idToObject;
+ private List? _propertiesToResolve;
+
+ public bool IsMergeMode { get; set; }
+ public bool UseReferenceHandling { get; private set; }
+ public byte MaxDepth { get; private set; }
+
+ public DeserializationContext(in AcJsonSerializerOptions options)
+ {
+ Reset(options);
+ }
+
+ public void Reset(in AcJsonSerializerOptions options)
+ {
+ UseReferenceHandling = options.UseReferenceHandling;
+ MaxDepth = options.MaxDepth;
+ IsMergeMode = false;
+ }
+
+ public void Clear()
+ {
+ _idToObject?.Clear();
+ _propertiesToResolve?.Clear();
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public void RegisterObject(string id, object obj)
+ {
+ if (!UseReferenceHandling) return;
+ _idToObject ??= new Dictionary(8, StringComparer.Ordinal);
+ _idToObject[id] = obj;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public bool TryGetReferencedObject(string id, out object? obj)
+ {
+ if (_idToObject != null) return _idToObject.TryGetValue(id, out obj);
+ obj = null;
+ return false;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public void AddPropertyToResolve(object target, PropertySetterInfo property, string refId)
+ {
+ _propertiesToResolve ??= new List(4);
+ _propertiesToResolve.Add(new PropertyToResolve(target, property, refId));
+ }
+
+ public void ResolveReferences()
+ {
+ if (_propertiesToResolve == null || _idToObject == null) return;
+
+ foreach (ref readonly var ptr in System.Runtime.InteropServices.CollectionsMarshal.AsSpan(_propertiesToResolve))
+ {
+ if (_idToObject.TryGetValue(ptr.RefId, out var refObj))
+ ptr.Property.SetValue(ptr.Target, refObj);
+ }
+ }
+ }
+}
diff --git a/AyCode.Core/Serializers/Jsons/AcJsonDeserializer.DeserializeTypeMetadata.cs b/AyCode.Core/Serializers/Jsons/AcJsonDeserializer.DeserializeTypeMetadata.cs
new file mode 100644
index 0000000..4ee2576
--- /dev/null
+++ b/AyCode.Core/Serializers/Jsons/AcJsonDeserializer.DeserializeTypeMetadata.cs
@@ -0,0 +1,236 @@
+using System.Collections;
+using System.Collections.Concurrent;
+using System.Collections.Frozen;
+using System.Linq.Expressions;
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using System.Text;
+using System.Text.Json;
+using static AyCode.Core.Helpers.JsonUtilities;
+
+namespace AyCode.Core.Serializers.Jsons;
+
+public static partial class AcJsonDeserializer
+{
+ private static readonly ConcurrentDictionary TypeMetadataCache = new();
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static DeserializeTypeMetadata GetTypeMetadata(in Type type)
+ => TypeMetadataCache.GetOrAdd(type, static t => new DeserializeTypeMetadata(t));
+
+ private sealed class DeserializeTypeMetadata
+ {
+ public FrozenDictionary PropertySettersFrozen { get; }
+ public PropertySetterInfo[] PropertiesArray { get; } // Array for fast UTF8 linear scan (small objects)
+ public Func? CompiledConstructor { get; }
+
+ public DeserializeTypeMetadata(Type type)
+ {
+ CompiledConstructor = AcSerializerCommon.CreateCompiledConstructor(type);
+
+ var allProps = type.GetProperties(BindingFlags.Public | BindingFlags.Instance);
+ var propsList = new List(allProps.Length);
+
+ foreach (var p in allProps)
+ {
+ if (!p.CanWrite || !p.CanRead || p.GetIndexParameters().Length != 0) continue;
+ if (HasJsonIgnoreAttribute(p)) continue;
+ propsList.Add(p);
+ }
+
+ var propertySetters = new Dictionary(propsList.Count, StringComparer.OrdinalIgnoreCase);
+ var propsArray = new PropertySetterInfo[propsList.Count];
+ var index = 0;
+
+ foreach (var prop in propsList)
+ {
+ var propInfo = new PropertySetterInfo(prop, type);
+ propertySetters[prop.Name] = propInfo;
+ propsArray[index++] = propInfo;
+ }
+
+ PropertiesArray = propsArray;
+ // Create frozen dictionary for faster lookup in hot paths
+ PropertySettersFrozen = propertySetters.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Try to find property by UTF-8 name using ValueTextEquals (avoids string allocation).
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public bool TryGetPropertyUtf8(ref Utf8JsonReader reader, out PropertySetterInfo? propInfo)
+ {
+ var props = PropertiesArray;
+ for (var i = 0; i < props.Length; i++)
+ {
+ if (reader.ValueTextEquals(props[i].NameUtf8))
+ {
+ propInfo = props[i];
+ return true;
+ }
+ }
+ propInfo = null;
+ return false;
+ }
+ }
+
+ private sealed class PropertySetterInfo
+ {
+ public readonly Type PropertyType;
+ public readonly Type UnderlyingType; // Cached: Nullable.GetUnderlyingType(PropertyType) ?? PropertyType
+ public readonly TypeCode PropertyTypeCode; // Cached TypeCode for fast primitive reading
+ public readonly bool IsNullable;
+ public readonly bool IsIIdCollection;
+ public readonly Type? ElementType;
+ public readonly Type? ElementIdType;
+ public readonly Func? ElementIdGetter;
+ public readonly byte[] NameUtf8; // Pre-computed UTF-8 bytes of property name for fast matching
+
+ // Typed setters to avoid boxing for primitives
+ private readonly Action _setter;
+ private readonly Func _getter;
+
+ // Typed setters for common primitive types (avoid boxing)
+ internal readonly Action? _setInt32;
+ internal readonly Action? _setInt64;
+ internal readonly Action? _setDouble;
+ internal readonly Action? _setBool;
+ internal readonly Action? _setDecimal;
+ internal readonly Action? _setSingle;
+ internal readonly Action? _setDateTime;
+ internal readonly Action? _setGuid;
+
+ // Pre-boxed boolean values to avoid repeated boxing
+ private static readonly object BoxedTrue = true;
+ private static readonly object BoxedFalse = false;
+
+ public PropertySetterInfo(PropertyInfo prop, Type declaringType)
+ {
+ PropertyType = prop.PropertyType;
+ var underlying = Nullable.GetUnderlyingType(PropertyType);
+ IsNullable = underlying != null;
+ UnderlyingType = underlying ?? PropertyType;
+ PropertyTypeCode = Type.GetTypeCode(UnderlyingType);
+ NameUtf8 = Encoding.UTF8.GetBytes(prop.Name);
+
+ _setter = AcSerializerCommon.CreateCompiledSetter(declaringType, prop);
+ _getter = AcSerializerCommon.CreateCompiledGetter(declaringType, prop);
+
+ // Create typed setters for common primitives to avoid boxing
+ if (!IsNullable)
+ {
+ if (ReferenceEquals(PropertyType, IntType))
+ _setInt32 = CreateTypedSetter(declaringType, prop);
+ else if (ReferenceEquals(PropertyType, LongType))
+ _setInt64 = CreateTypedSetter(declaringType, prop);
+ else if (ReferenceEquals(PropertyType, DoubleType))
+ _setDouble = CreateTypedSetter(declaringType, prop);
+ else if (ReferenceEquals(PropertyType, BoolType))
+ _setBool = CreateTypedSetter(declaringType, prop);
+ else if (ReferenceEquals(PropertyType, DecimalType))
+ _setDecimal = CreateTypedSetter(declaringType, prop);
+ else if (ReferenceEquals(PropertyType, FloatType))
+ _setSingle = CreateTypedSetter(declaringType, prop);
+ else if (ReferenceEquals(PropertyType, DateTimeType))
+ _setDateTime = CreateTypedSetter(declaringType, prop);
+ else if (ReferenceEquals(PropertyType, GuidType))
+ _setGuid = CreateTypedSetter(declaringType, prop);
+ }
+
+ ElementType = GetCollectionElementType(PropertyType);
+ var isCollection = ElementType != null && ElementType != typeof(object) &&
+ typeof(IEnumerable).IsAssignableFrom(PropertyType) &&
+ !ReferenceEquals(PropertyType, StringType);
+
+ if (isCollection && ElementType != null)
+ {
+ var idInfo = GetIdInfo(ElementType);
+ if (idInfo.IsId)
+ {
+ IsIIdCollection = true;
+ ElementIdType = idInfo.IdType;
+ var idProp = ElementType.GetProperty("Id");
+ if (idProp != null)
+ ElementIdGetter = AcSerializerCommon.CreateCompiledGetter(ElementType, idProp);
+ }
+ }
+ }
+
+ private static Action CreateTypedSetter(Type declaringType, PropertyInfo prop)
+ {
+ var objParam = Expression.Parameter(typeof(object), "obj");
+ var valueParam = Expression.Parameter(typeof(T), "value");
+ var castObj = Expression.Convert(objParam, declaringType);
+ var propAccess = Expression.Property(castObj, prop);
+ var assign = Expression.Assign(propAccess, valueParam);
+ return Expression.Lambda>(assign, objParam, valueParam).Compile();
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public void SetValue(object target, object? value) => _setter(target, value);
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public object? GetValue(object target) => _getter(target);
+
+ ///
+ /// Read and set value directly from Utf8JsonReader, avoiding boxing for primitives.
+ /// Returns true if value was set, false if it needs fallback to SetValue.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public bool TrySetValueDirect(object target, ref Utf8JsonReader reader)
+ {
+ var tokenType = reader.TokenType;
+
+ // Handle null
+ if (tokenType == JsonTokenType.Null)
+ {
+ if (IsNullable || !PropertyType.IsValueType)
+ {
+ _setter(target, null);
+ return true;
+ }
+ return true; // Skip null for non-nullable value types
+ }
+
+ // Fast path for booleans - no boxing needed with typed setter
+ if (tokenType == JsonTokenType.True)
+ {
+ if (_setBool != null) { _setBool(target, true); return true; }
+ _setter(target, BoxedTrue);
+ return true;
+ }
+ if (tokenType == JsonTokenType.False)
+ {
+ if (_setBool != null) { _setBool(target, false); return true; }
+ _setter(target, BoxedFalse);
+ return true;
+ }
+
+ // Fast path for numbers - use typed setters when available
+ if (tokenType == JsonTokenType.Number)
+ {
+ if (_setInt32 != null) { _setInt32(target, reader.GetInt32()); return true; }
+ if (_setInt64 != null) { _setInt64(target, reader.GetInt64()); return true; }
+ if (_setDouble != null) { _setDouble(target, reader.GetDouble()); return true; }
+ if (_setDecimal != null) { _setDecimal(target, reader.GetDecimal()); return true; }
+ if (_setSingle != null) { _setSingle(target, reader.GetSingle()); return true; }
+ return false; // Fallback to boxed path
+ }
+
+ // Fast path for strings - common types
+ if (tokenType == JsonTokenType.String)
+ {
+ if (ReferenceEquals(UnderlyingType, StringType))
+ {
+ _setter(target, reader.GetString());
+ return true;
+ }
+ if (_setDateTime != null) { _setDateTime(target, reader.GetDateTime()); return true; }
+ if (_setGuid != null) { _setGuid(target, reader.GetGuid()); return true; }
+ return false; // Fallback to boxed path
+ }
+
+ return false; // Complex types need standard handling
+ }
+ }
+}
diff --git a/AyCode.Core/Serializers/Jsons/AcJsonDeserializer.JsonElement.cs b/AyCode.Core/Serializers/Jsons/AcJsonDeserializer.JsonElement.cs
new file mode 100644
index 0000000..fc7961d
--- /dev/null
+++ b/AyCode.Core/Serializers/Jsons/AcJsonDeserializer.JsonElement.cs
@@ -0,0 +1,313 @@
+using System.Collections;
+using System.Globalization;
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using AyCode.Core.Helpers;
+using static AyCode.Core.Helpers.JsonUtilities;
+
+namespace AyCode.Core.Serializers.Jsons;
+
+public static partial class AcJsonDeserializer
+{
+ #region With Reference Handling (JsonElement Path)
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static object? ReadValue(in JsonElement element, in Type targetType, DeserializationContext context, int depth)
+ {
+ var kind = element.ValueKind;
+ if (kind == JsonValueKind.Object) return ReadObject(element, targetType, context, depth);
+ if (kind == JsonValueKind.Array) return ReadArray(element, targetType, context, depth);
+ if (kind == JsonValueKind.Null || kind == JsonValueKind.Undefined) return null;
+ return ReadPrimitive(element, targetType, kind);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static object? ReadObject(in JsonElement element, in Type targetType, DeserializationContext context, int depth)
+ {
+ // Check for $ref first
+ if (element.TryGetProperty(RefPropertyUtf8, out var refElement))
+ {
+ var refId = refElement.GetString()!;
+ return context.TryGetReferencedObject(refId, out var refObj) ? refObj : new DeferredReference(refId, targetType);
+ }
+
+ if (depth > context.MaxDepth) return null;
+
+ if (IsDictionaryType(targetType, out var keyType, out var valueType))
+ return ReadDictionary(element, keyType!, valueType!, context, depth);
+
+ var metadata = GetTypeMetadata(targetType);
+
+ var instance = metadata.CompiledConstructor?.Invoke();
+ if (instance == null)
+ {
+ try { instance = Activator.CreateInstance(targetType); }
+ catch (MissingMethodException ex)
+ {
+ throw new AcJsonDeserializationException(
+ $"Cannot deserialize type '{targetType.FullName}' because it does not have a parameterless constructor.",
+ null, targetType, ex);
+ }
+ }
+
+ if (instance == null) return null;
+
+ // Check for $id and register
+ if (element.TryGetProperty(IdPropertyUtf8, out var idElement))
+ context.RegisterObject(idElement.GetString()!, instance);
+
+ PopulateObjectInternal(element, instance, metadata, context, depth);
+
+ return instance;
+ }
+
+ private static void PopulateObjectInternal(in JsonElement element, object target, DeserializeTypeMetadata metadata, DeserializationContext context, int depth)
+ {
+ var propsDict = metadata.PropertySettersFrozen;
+ var nextDepth = depth + 1;
+
+ foreach (var jsonProp in element.EnumerateObject())
+ {
+ var propName = jsonProp.Name;
+
+ // Skip $ properties
+ if (propName.Length > 0 && propName[0] == '$') continue;
+
+ if (!propsDict.TryGetValue(propName, out var propInfo)) continue;
+
+ var value = ReadValue(jsonProp.Value, propInfo.PropertyType, context, nextDepth);
+
+ if (value is DeferredReference deferred)
+ context.AddPropertyToResolve(target, propInfo, deferred.RefId);
+ else
+ propInfo.SetValue(target, value);
+ }
+ }
+
+ private static void PopulateObjectInternalMerge(in JsonElement element, object target, DeserializeTypeMetadata metadata, DeserializationContext context, int depth)
+ {
+ var propsDict = metadata.PropertySettersFrozen;
+ var nextDepth = depth + 1;
+ var maxDepthReached = nextDepth > context.MaxDepth;
+
+ foreach (var jsonProp in element.EnumerateObject())
+ {
+ var propName = jsonProp.Name;
+
+ // Skip $ properties
+ if (propName.Length > 0 && propName[0] == '$') continue;
+
+ if (!propsDict.TryGetValue(propName, out var propInfo)) continue;
+
+ var propValue = jsonProp.Value;
+ var propValueKind = propValue.ValueKind;
+
+ if (maxDepthReached)
+ {
+ if (propValueKind != JsonValueKind.Object && propValueKind != JsonValueKind.Array)
+ {
+ var primitiveValue = ReadPrimitive(propValue, propInfo.PropertyType, propValueKind);
+ propInfo.SetValue(target, primitiveValue);
+ }
+ continue;
+ }
+
+ // Handle IId collection merge
+ if (propInfo.IsIIdCollection && propValueKind == JsonValueKind.Array)
+ {
+ var existingCollection = propInfo.GetValue(target);
+ if (existingCollection != null)
+ {
+ MergeIIdCollection(propValue, existingCollection, propInfo, context, depth);
+ continue;
+ }
+ }
+
+ // Handle nested objects
+ if (propValueKind == JsonValueKind.Object)
+ {
+ // Check for $ref
+ if (propValue.TryGetProperty(RefPropertyUtf8, out _))
+ {
+ var value = ReadValue(propValue, propInfo.PropertyType, context, nextDepth);
+ if (value is DeferredReference deferred)
+ context.AddPropertyToResolve(target, propInfo, deferred.RefId);
+ else
+ propInfo.SetValue(target, value);
+ continue;
+ }
+
+ // Merge into existing object
+ if (!propInfo.PropertyType.IsPrimitive && !ReferenceEquals(propInfo.PropertyType, StringType))
+ {
+ var existingObj = propInfo.GetValue(target);
+ if (existingObj != null)
+ {
+ var nestedMetadata = GetTypeMetadata(propInfo.PropertyType);
+ PopulateObjectInternalMerge(propValue, existingObj, nestedMetadata, context, nextDepth);
+ continue;
+ }
+ }
+ }
+
+ var value2 = ReadValue(propValue, propInfo.PropertyType, context, nextDepth);
+
+ if (value2 is DeferredReference deferred2)
+ context.AddPropertyToResolve(target, propInfo, deferred2.RefId);
+ else
+ propInfo.SetValue(target, value2);
+ }
+ }
+
+ private static void PopulateList(in JsonElement arrayElement, IList targetList, in Type listType, DeserializationContext context, int depth)
+ {
+ if (depth > context.MaxDepth) return;
+
+ var elementType = GetCollectionElementType(listType);
+ if (elementType == null) return;
+
+ var acObservable = targetList as IAcObservableCollection;
+ acObservable?.BeginUpdate();
+
+ try
+ {
+ targetList.Clear();
+ var nextDepth = depth + 1;
+
+ foreach (var item in arrayElement.EnumerateArray())
+ {
+ var value = ReadValue(item, elementType, context, nextDepth);
+ if (value != null)
+ targetList.Add(value);
+ }
+ }
+ finally { acObservable?.EndUpdate(); }
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static object? ReadArray(in JsonElement element, in Type targetType, DeserializationContext context, int depth)
+ {
+ if (depth > context.MaxDepth) return null;
+
+ var elementType = GetCollectionElementType(targetType);
+ if (elementType == null) return null;
+
+ var nextDepth = depth + 1;
+
+ if (targetType.IsArray)
+ {
+ var list = GetOrCreateListFactory(elementType)();
+ foreach (var item in element.EnumerateArray())
+ list.Add(ReadValue(item, elementType, context, nextDepth));
+
+ var array = Array.CreateInstance(elementType, list.Count);
+ list.CopyTo(array, 0);
+ return array;
+ }
+
+ IList? targetList = null;
+ try
+ {
+ var instance = Activator.CreateInstance(targetType);
+ if (instance is IList list) targetList = list;
+ }
+ catch { /* Fallback to List */ }
+
+ targetList ??= GetOrCreateListFactory(elementType)();
+
+ var acObservable = targetList as IAcObservableCollection;
+ acObservable?.BeginUpdate();
+
+ try
+ {
+ foreach (var item in element.EnumerateArray())
+ targetList.Add(ReadValue(item, elementType, context, nextDepth));
+ }
+ finally { acObservable?.EndUpdate(); }
+
+ return targetList;
+ }
+
+ private static void MergeIIdCollection(in JsonElement arrayElement, object existingCollection, PropertySetterInfo propInfo, DeserializationContext context, int depth)
+ {
+ var elementType = propInfo.ElementType!;
+ var idGetter = propInfo.ElementIdGetter!;
+ var idType = propInfo.ElementIdType!;
+
+ var existingList = (IList)existingCollection;
+ var count = existingList.Count;
+
+ var acObservable = existingList as IAcObservableCollection;
+ acObservable?.BeginUpdate();
+
+ try
+ {
+ Dictionary? existingById = null;
+ if (count > 0)
+ {
+ existingById = new Dictionary(count);
+ for (var i = 0; i < count; i++)
+ {
+ var item = existingList[i];
+ if (item != null)
+ {
+ var id = idGetter(item);
+ if (id != null && !IsDefaultValue(id, idType))
+ existingById[id] = item;
+ }
+ }
+ }
+
+ var nextDepth = depth + 1;
+ foreach (var jsonItem in arrayElement.EnumerateArray())
+ {
+ if (jsonItem.ValueKind != JsonValueKind.Object) continue;
+
+ object? itemId = null;
+ if (jsonItem.TryGetProperty("Id", out var idProp))
+ itemId = ReadPrimitive(idProp, idType, idProp.ValueKind);
+
+ if (itemId != null && !IsDefaultValue(itemId, idType) && existingById != null)
+ {
+ if (existingById.TryGetValue(itemId, out var existingItem))
+ {
+ var itemMetadata = GetTypeMetadata(elementType);
+ PopulateObjectInternalMerge(jsonItem, existingItem, itemMetadata, context, nextDepth);
+ continue;
+ }
+ }
+
+ var newItem = ReadValue(jsonItem, elementType, context, nextDepth);
+ if (newItem != null) existingList.Add(newItem);
+ }
+ }
+ finally { acObservable?.EndUpdate(); }
+ }
+
+ private static object ReadDictionary(in JsonElement element, in Type keyType, in Type valueType, DeserializationContext context, int depth)
+ {
+ var dictType = DictionaryGenericType.MakeGenericType(keyType, valueType);
+ var dict = (IDictionary)Activator.CreateInstance(dictType)!;
+ var nextDepth = depth + 1;
+
+ foreach (var prop in element.EnumerateObject())
+ {
+ var name = prop.Name;
+ if (name.Length > 0 && name[0] == '$') continue;
+
+ object key;
+ if (ReferenceEquals(keyType, StringType)) key = name;
+ else if (ReferenceEquals(keyType, IntType)) key = int.Parse(name, CultureInfo.InvariantCulture);
+ else if (ReferenceEquals(keyType, LongType)) key = long.Parse(name, CultureInfo.InvariantCulture);
+ else if (ReferenceEquals(keyType, GuidType)) key = Guid.Parse(name);
+ else if (keyType.IsEnum) key = Enum.Parse(keyType, name);
+ else key = Convert.ChangeType(name, keyType, CultureInfo.InvariantCulture);
+
+ dict.Add(key, ReadValue(prop.Value, valueType, context, nextDepth));
+ }
+
+ return dict;
+ }
+
+ #endregion
+}
diff --git a/AyCode.Core/Serializers/Jsons/AcJsonDeserializer.Primitives.cs b/AyCode.Core/Serializers/Jsons/AcJsonDeserializer.Primitives.cs
new file mode 100644
index 0000000..a9875d9
--- /dev/null
+++ b/AyCode.Core/Serializers/Jsons/AcJsonDeserializer.Primitives.cs
@@ -0,0 +1,173 @@
+using System.Globalization;
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using static AyCode.Core.Helpers.JsonUtilities;
+
+namespace AyCode.Core.Serializers.Jsons;
+
+public static partial class AcJsonDeserializer
+{
+ #region Primitive Reading
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static object? ReadPrimitive(in JsonElement element, in Type targetType, JsonValueKind valueKind)
+ {
+ var type = Nullable.GetUnderlyingType(targetType) ?? targetType;
+
+ if (valueKind == JsonValueKind.Number)
+ {
+ var typeCode = Type.GetTypeCode(type);
+ return typeCode switch
+ {
+ TypeCode.Int32 => element.GetInt32(),
+ TypeCode.Int64 => element.GetInt64(),
+ TypeCode.Double => element.GetDouble(),
+ TypeCode.Decimal => element.GetDecimal(),
+ TypeCode.Single => element.GetSingle(),
+ TypeCode.Byte => element.GetByte(),
+ TypeCode.Int16 => element.GetInt16(),
+ TypeCode.UInt16 => element.GetUInt16(),
+ TypeCode.UInt32 => element.GetUInt32(),
+ TypeCode.UInt64 => element.GetUInt64(),
+ TypeCode.SByte => element.GetSByte(),
+ _ => type.IsEnum ? Enum.ToObject(type, element.GetInt32()) : null
+ };
+ }
+
+ 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 (ReferenceEquals(type, DateTimeOffsetType)) return element.GetDateTimeOffset();
+ if (ReferenceEquals(type, TimeSpanType)) return TimeSpan.Parse(element.GetString()!, CultureInfo.InvariantCulture);
+ if (type.IsEnum) return Enum.Parse(type, element.GetString()!);
+ return null;
+ }
+
+ if (valueKind == JsonValueKind.True) return true;
+ if (valueKind == JsonValueKind.False) return false;
+
+ return null;
+ }
+
+ #endregion
+
+ #region Primitive Deserialization
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static bool TryDeserializePrimitiveFast(string json, in Type targetType, out object? result)
+ {
+ var type = Nullable.GetUnderlyingType(targetType) ?? targetType;
+
+ // Handle enums first
+ if (type.IsEnum)
+ {
+ if (json.Length > 0 && json[0] == '"')
+ {
+ using var doc = JsonDocument.Parse(json);
+ result = Enum.Parse(type, doc.RootElement.GetString()!);
+ }
+ else result = Enum.ToObject(type, int.Parse(json, CultureInfo.InvariantCulture));
+ return true;
+ }
+
+ var typeCode = Type.GetTypeCode(type);
+
+ switch (typeCode)
+ {
+ case TypeCode.Int32: result = int.Parse(json, CultureInfo.InvariantCulture); return true;
+ case TypeCode.Int64: result = long.Parse(json, CultureInfo.InvariantCulture); return true;
+ case TypeCode.Boolean: result = json == "true"; return true;
+ case TypeCode.Double: result = double.Parse(json, CultureInfo.InvariantCulture); return true;
+ case TypeCode.Decimal: result = decimal.Parse(json, CultureInfo.InvariantCulture); return true;
+ case TypeCode.Single: result = float.Parse(json, CultureInfo.InvariantCulture); return true;
+ case TypeCode.String:
+ // If already unwrapped (no quotes), return as-is; otherwise parse JSON
+ if (json.Length == 0 || json[0] != '"')
+ {
+ result = json;
+ return true;
+ }
+ using (var doc = JsonDocument.Parse(json))
+ {
+ result = doc.RootElement.GetString();
+ return true;
+ }
+ case TypeCode.DateTime:
+ // If already unwrapped (no quotes), parse directly; otherwise use JSON parser
+ if (json.Length == 0 || json[0] != '"')
+ {
+ result = DateTime.Parse(json, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
+ return true;
+ }
+ using (var doc = JsonDocument.Parse(json))
+ {
+ result = doc.RootElement.GetDateTime();
+ return true;
+ }
+ case TypeCode.Byte: result = byte.Parse(json, CultureInfo.InvariantCulture); return true;
+ case TypeCode.Int16: result = short.Parse(json, CultureInfo.InvariantCulture); return true;
+ case TypeCode.UInt16: result = ushort.Parse(json, CultureInfo.InvariantCulture); return true;
+ case TypeCode.UInt32: result = uint.Parse(json, CultureInfo.InvariantCulture); return true;
+ case TypeCode.UInt64: result = ulong.Parse(json, CultureInfo.InvariantCulture); return true;
+ case TypeCode.SByte: result = sbyte.Parse(json, CultureInfo.InvariantCulture); return true;
+ case TypeCode.Char:
+ if (json.Length == 0 || json[0] != '"')
+ {
+ result = json.Length > 0 ? json[0] : '\0';
+ return true;
+ }
+ using (var doc = JsonDocument.Parse(json))
+ {
+ var s = doc.RootElement.GetString();
+ result = s?.Length > 0 ? s[0] : '\0';
+ return true;
+ }
+ }
+
+ if (ReferenceEquals(type, GuidType))
+ {
+ // If already unwrapped (no quotes), parse directly
+ if (json.Length == 0 || json[0] != '"')
+ {
+ result = Guid.Parse(json);
+ return true;
+ }
+ using var doc = JsonDocument.Parse(json);
+ result = doc.RootElement.GetGuid();
+ return true;
+ }
+
+ if (ReferenceEquals(type, DateTimeOffsetType))
+ {
+ // If already unwrapped (no quotes), parse directly
+ if (json.Length == 0 || json[0] != '"')
+ {
+ result = DateTimeOffset.Parse(json, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
+ return true;
+ }
+ using var doc = JsonDocument.Parse(json);
+ result = doc.RootElement.GetDateTimeOffset();
+ return true;
+ }
+
+ if (ReferenceEquals(type, TimeSpanType))
+ {
+ // If already unwrapped (no quotes), parse directly
+ if (json.Length == 0 || json[0] != '"')
+ {
+ result = TimeSpan.Parse(json, CultureInfo.InvariantCulture);
+ return true;
+ }
+ using var doc = JsonDocument.Parse(json);
+ result = TimeSpan.Parse(doc.RootElement.GetString()!, CultureInfo.InvariantCulture);
+ return true;
+ }
+
+ result = null;
+ return false;
+ }
+
+ #endregion
+}
diff --git a/AyCode.Core/Serializers/Jsons/AcJsonDeserializer.Utf8Reader.cs b/AyCode.Core/Serializers/Jsons/AcJsonDeserializer.Utf8Reader.cs
new file mode 100644
index 0000000..a38cc72
--- /dev/null
+++ b/AyCode.Core/Serializers/Jsons/AcJsonDeserializer.Utf8Reader.cs
@@ -0,0 +1,634 @@
+using System.Collections;
+using System.Globalization;
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using AyCode.Core.Helpers;
+using static AyCode.Core.Helpers.JsonUtilities;
+
+namespace AyCode.Core.Serializers.Jsons;
+
+public static partial class AcJsonDeserializer
+{
+ #region Utf8JsonReader Fast Path (STJ-style streaming)
+
+ ///
+ /// Deserialize using Utf8JsonReader - streaming without DOM allocation.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static T? DeserializeWithUtf8Reader(string json, byte maxDepth)
+ {
+ var (buffer, length) = GetUtf8Bytes(json);
+ try
+ {
+ var reader = new Utf8JsonReader(buffer.AsSpan(0, length), new JsonReaderOptions { MaxDepth = maxDepth });
+ if (!reader.Read()) return default;
+ return (T?)ReadValueFromReader(ref reader, typeof(T), maxDepth, 0);
+ }
+ finally
+ {
+ ReturnUtf8Buffer(buffer);
+ }
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static object? DeserializeWithUtf8ReaderNonGeneric(string json, Type targetType, byte maxDepth)
+ {
+ var (buffer, length) = GetUtf8Bytes(json);
+ try
+ {
+ var reader = new Utf8JsonReader(buffer.AsSpan(0, length), new JsonReaderOptions { MaxDepth = maxDepth });
+ if (!reader.Read()) return null;
+ return ReadValueFromReader(ref reader, targetType, maxDepth, 0);
+ }
+ finally
+ {
+ ReturnUtf8Buffer(buffer);
+ }
+ }
+
+ private static object? ReadValueFromReader(ref Utf8JsonReader reader, Type targetType, byte maxDepth, int depth)
+ {
+ switch (reader.TokenType)
+ {
+ case JsonTokenType.Null:
+ return null;
+ case JsonTokenType.True:
+ return true;
+ case JsonTokenType.False:
+ return false;
+ case JsonTokenType.Number:
+ return ReadNumberFromReader(ref reader, targetType);
+ case JsonTokenType.String:
+ return ReadStringFromReader(ref reader, targetType);
+ case JsonTokenType.StartObject:
+ return ReadObjectFromReader(ref reader, targetType, maxDepth, depth);
+ case JsonTokenType.StartArray:
+ return ReadArrayFromReader(ref reader, targetType, maxDepth, depth);
+ default:
+ return null;
+ }
+ }
+
+ ///
+ /// Read number value using cached PropertySetterInfo (avoids Nullable.GetUnderlyingType + Type.GetTypeCode calls).
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static object? ReadNumberFromReaderCached(ref Utf8JsonReader reader, PropertySetterInfo propInfo)
+ {
+ return propInfo.PropertyTypeCode switch
+ {
+ TypeCode.Int32 => reader.GetInt32(),
+ TypeCode.Int64 => reader.GetInt64(),
+ TypeCode.Double => reader.GetDouble(),
+ TypeCode.Decimal => reader.GetDecimal(),
+ TypeCode.Single => reader.GetSingle(),
+ TypeCode.Byte => reader.GetByte(),
+ TypeCode.Int16 => reader.GetInt16(),
+ TypeCode.UInt16 => reader.GetUInt16(),
+ TypeCode.UInt32 => reader.GetUInt32(),
+ TypeCode.UInt64 => reader.GetUInt64(),
+ TypeCode.SByte => reader.GetSByte(),
+ _ => propInfo.UnderlyingType.IsEnum ? Enum.ToObject(propInfo.UnderlyingType, reader.GetInt32()) : reader.GetDouble()
+ };
+ }
+
+ ///
+ /// Read string value using cached PropertySetterInfo (avoids Nullable.GetUnderlyingType calls).
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static object? ReadStringFromReaderCached(ref Utf8JsonReader reader, PropertySetterInfo propInfo)
+ {
+ var type = propInfo.UnderlyingType;
+
+ if (ReferenceEquals(type, StringType)) return reader.GetString();
+ if (ReferenceEquals(type, DateTimeType)) return reader.GetDateTime();
+ if (ReferenceEquals(type, GuidType)) return reader.GetGuid();
+ if (ReferenceEquals(type, DateTimeOffsetType)) return reader.GetDateTimeOffset();
+ if (ReferenceEquals(type, TimeSpanType))
+ {
+ var str = reader.GetString();
+ return str != null ? TimeSpan.Parse(str, CultureInfo.InvariantCulture) : default(TimeSpan);
+ }
+ if (type.IsEnum)
+ {
+ var str = reader.GetString();
+ return str != null ? Enum.Parse(type, str) : null;
+ }
+
+ return reader.GetString();
+ }
+
+ ///
+ /// Read value from reader using cached PropertySetterInfo for faster type resolution.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static object? ReadValueFromReaderCached(ref Utf8JsonReader reader, PropertySetterInfo propInfo, byte maxDepth, int depth)
+ {
+ switch (reader.TokenType)
+ {
+ case JsonTokenType.Null:
+ return null;
+ case JsonTokenType.True:
+ return true;
+ case JsonTokenType.False:
+ return false;
+ case JsonTokenType.Number:
+ return ReadNumberFromReaderCached(ref reader, propInfo);
+ case JsonTokenType.String:
+ return ReadStringFromReaderCached(ref reader, propInfo);
+ case JsonTokenType.StartObject:
+ return ReadObjectFromReader(ref reader, propInfo.PropertyType, maxDepth, depth);
+ case JsonTokenType.StartArray:
+ return ReadArrayFromReader(ref reader, propInfo.PropertyType, maxDepth, depth);
+ default:
+ return null;
+ }
+ }
+
+ private static object? ReadNumberFromReader(ref Utf8JsonReader reader, Type targetType)
+ {
+ var type = Nullable.GetUnderlyingType(targetType) ?? targetType;
+ var typeCode = Type.GetTypeCode(type);
+
+ return typeCode switch
+ {
+ TypeCode.Int32 => reader.GetInt32(),
+ TypeCode.Int64 => reader.GetInt64(),
+ TypeCode.Double => reader.GetDouble(),
+ TypeCode.Decimal => reader.GetDecimal(),
+ TypeCode.Single => reader.GetSingle(),
+ TypeCode.Byte => reader.GetByte(),
+ TypeCode.Int16 => reader.GetInt16(),
+ TypeCode.UInt16 => reader.GetUInt16(),
+ TypeCode.UInt32 => reader.GetUInt32(),
+ TypeCode.UInt64 => reader.GetUInt64(),
+ TypeCode.SByte => reader.GetSByte(),
+ _ => type.IsEnum ? Enum.ToObject(type, reader.GetInt32()) : reader.GetDouble()
+ };
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static object? ReadStringFromReader(ref Utf8JsonReader reader, Type targetType)
+ {
+ var type = Nullable.GetUnderlyingType(targetType) ?? targetType;
+
+ if (ReferenceEquals(type, StringType)) return reader.GetString();
+ if (ReferenceEquals(type, DateTimeType)) return reader.GetDateTime();
+ if (ReferenceEquals(type, GuidType)) return reader.GetGuid();
+ if (ReferenceEquals(type, DateTimeOffsetType)) return reader.GetDateTimeOffset();
+ if (ReferenceEquals(type, TimeSpanType))
+ {
+ var str = reader.GetString();
+ return str != null ? TimeSpan.Parse(str, CultureInfo.InvariantCulture) : default(TimeSpan);
+ }
+ if (type.IsEnum)
+ {
+ var str = reader.GetString();
+ return str != null ? Enum.Parse(type, str) : null;
+ }
+
+ return reader.GetString();
+ }
+
+ private static object? ReadObjectFromReader(ref Utf8JsonReader reader, Type targetType, byte maxDepth, int depth)
+ {
+ if (depth > maxDepth)
+ {
+ reader.Skip();
+ return null;
+ }
+
+ if (IsDictionaryType(targetType, out var keyType, out var valueType))
+ return ReadDictionaryFromReader(ref reader, keyType!, valueType!, maxDepth, depth);
+
+ var metadata = GetTypeMetadata(targetType);
+
+ var instance = metadata.CompiledConstructor?.Invoke();
+ if (instance == null)
+ {
+ try { instance = Activator.CreateInstance(targetType); }
+ catch (MissingMethodException ex)
+ {
+ throw new AcJsonDeserializationException(
+ $"Cannot deserialize type '{targetType.FullName}' because it does not have a parameterless constructor.",
+ null, targetType, ex);
+ }
+ }
+
+ if (instance == null) return null;
+
+ var nextDepth = depth + 1;
+
+ while (reader.Read())
+ {
+ if (reader.TokenType == JsonTokenType.EndObject)
+ break;
+
+ if (reader.TokenType != JsonTokenType.PropertyName)
+ continue;
+
+ // Use UTF8 lookup to avoid string allocation
+ if (!metadata.TryGetPropertyUtf8(ref reader, out var propInfo) || propInfo == null)
+ {
+ if (!reader.Read()) break;
+ reader.Skip();
+ continue;
+ }
+
+ if (!reader.Read())
+ break;
+
+ // Try direct set for primitives (no boxing)
+ if (propInfo.TrySetValueDirect(instance, ref reader))
+ continue;
+
+ // Fallback to boxed path for complex types
+ var value = ReadValueFromReaderCached(ref reader, propInfo, maxDepth, nextDepth);
+ propInfo.SetValue(instance, value);
+ }
+
+ return instance;
+ }
+
+ ///
+ /// Read object from reader when we're already at StartObject.
+ ///
+ private static object? ReadObjectFromReaderAtStart(ref Utf8JsonReader reader, Type targetType, DeserializeTypeMetadata metadata, byte maxDepth, int depth)
+ {
+ if (depth > maxDepth)
+ {
+ reader.Skip();
+ return null;
+ }
+
+ var instance = metadata.CompiledConstructor?.Invoke();
+ if (instance == null)
+ {
+ try { instance = Activator.CreateInstance(targetType); }
+ catch (MissingMethodException ex)
+ {
+ throw new AcJsonDeserializationException(
+ $"Cannot deserialize type '{targetType.FullName}' because it does not have a parameterless constructor.",
+ null, targetType, ex);
+ }
+ }
+
+ if (instance == null) return null;
+
+ var nextDepth = depth + 1;
+
+ while (reader.Read())
+ {
+ if (reader.TokenType == JsonTokenType.EndObject)
+ break;
+
+ if (reader.TokenType != JsonTokenType.PropertyName)
+ continue;
+
+ // Use UTF8 lookup to avoid string allocation
+ if (!metadata.TryGetPropertyUtf8(ref reader, out var propInfo) || propInfo == null)
+ {
+ if (!reader.Read()) break;
+ reader.Skip();
+ continue;
+ }
+
+ if (!reader.Read())
+ break;
+
+ // Try direct set for primitives (no boxing)
+ if (propInfo.TrySetValueDirect(instance, ref reader))
+ continue;
+
+ // Fallback to boxed path for complex types
+ var value = ReadValueFromReaderCached(ref reader, propInfo, maxDepth, nextDepth);
+ propInfo.SetValue(instance, value);
+ }
+
+ return instance;
+ }
+
+ private static object? ReadArrayFromReader(ref Utf8JsonReader reader, Type targetType, byte maxDepth, int depth)
+ {
+ if (depth > maxDepth)
+ {
+ reader.Skip();
+ return null;
+ }
+
+ var elementType = GetCollectionElementType(targetType);
+ if (elementType == null) return null;
+
+ var nextDepth = depth + 1;
+ var list = GetOrCreateListFactory(elementType)();
+
+ while (reader.Read())
+ {
+ if (reader.TokenType == JsonTokenType.EndArray)
+ break;
+
+ var value = ReadValueFromReader(ref reader, elementType, maxDepth, nextDepth);
+ if (value != null)
+ list.Add(value);
+ }
+
+ if (targetType.IsArray)
+ {
+ var array = Array.CreateInstance(elementType, list.Count);
+ list.CopyTo(array, 0);
+ return array;
+ }
+
+ return list;
+ }
+
+ private static object ReadDictionaryFromReader(ref Utf8JsonReader reader, Type keyType, Type valueType, byte maxDepth, int depth)
+ {
+ var dictType = DictionaryGenericType.MakeGenericType(keyType, valueType);
+ var dict = (IDictionary)Activator.CreateInstance(dictType)!;
+ var nextDepth = depth + 1;
+
+ while (reader.Read())
+ {
+ if (reader.TokenType == JsonTokenType.EndObject)
+ break;
+
+ if (reader.TokenType != JsonTokenType.PropertyName)
+ continue;
+
+ var keyStr = reader.GetString();
+ if (keyStr == null || !reader.Read())
+ continue;
+
+ object key;
+ if (ReferenceEquals(keyType, StringType)) key = keyStr;
+ else if (ReferenceEquals(keyType, IntType)) key = int.Parse(keyStr, CultureInfo.InvariantCulture);
+ else if (ReferenceEquals(keyType, LongType)) key = long.Parse(keyStr, CultureInfo.InvariantCulture);
+ else if (ReferenceEquals(keyType, GuidType)) key = Guid.Parse(keyStr);
+ else if (keyType.IsEnum) key = Enum.Parse(keyType, keyStr);
+ else key = Convert.ChangeType(keyStr, keyType, CultureInfo.InvariantCulture);
+
+ var value = ReadValueFromReader(ref reader, valueType, maxDepth, nextDepth);
+ dict.Add(key, value);
+ }
+
+ return dict;
+ }
+
+ ///
+ /// Populate object using Utf8JsonReader streaming (no DOM allocation).
+ ///
+ private static void PopulateObjectWithUtf8Reader(string json, object target, DeserializeTypeMetadata metadata, byte maxDepth)
+ {
+ var (buffer, length) = GetUtf8Bytes(json);
+ try
+ {
+ var reader = new Utf8JsonReader(buffer.AsSpan(0, length), new JsonReaderOptions { MaxDepth = maxDepth });
+
+ if (!reader.Read() || reader.TokenType != JsonTokenType.StartObject)
+ return;
+
+ PopulateObjectMergeFromReader(ref reader, target, metadata, maxDepth, 0);
+ }
+ finally
+ {
+ ReturnUtf8Buffer(buffer);
+ }
+ }
+
+ ///
+ /// Populate list using Utf8JsonReader streaming (no DOM allocation).
+ ///
+ private static void PopulateListWithUtf8Reader(string json, IList targetList, in Type listType, byte maxDepth)
+ {
+ var (buffer, length) = GetUtf8Bytes(json);
+ try
+ {
+ var reader = new Utf8JsonReader(buffer.AsSpan(0, length), new JsonReaderOptions { MaxDepth = maxDepth });
+
+ if (!reader.Read() || reader.TokenType != JsonTokenType.StartArray)
+ return;
+
+ var elementType = GetCollectionElementType(listType);
+ if (elementType == null) return;
+
+ var acObservable = targetList as IAcObservableCollection;
+ acObservable?.BeginUpdate();
+
+ try
+ {
+ targetList.Clear();
+
+ while (reader.Read())
+ {
+ if (reader.TokenType == JsonTokenType.EndArray)
+ break;
+
+ var value = ReadValueFromReader(ref reader, elementType, maxDepth, 1);
+ if (value != null)
+ targetList.Add(value);
+ }
+ }
+ finally { acObservable?.EndUpdate(); }
+ }
+ finally
+ {
+ ReturnUtf8Buffer(buffer);
+ }
+ }
+
+ ///
+ /// Populate object with merge semantics from Utf8JsonReader.
+ ///
+ private static void PopulateObjectMergeFromReader(ref Utf8JsonReader reader, object target, DeserializeTypeMetadata metadata, byte maxDepth, int depth)
+ {
+ var nextDepth = depth + 1;
+ var maxDepthReached = nextDepth > maxDepth;
+
+ while (reader.Read())
+ {
+ if (reader.TokenType == JsonTokenType.EndObject)
+ break;
+
+ if (reader.TokenType != JsonTokenType.PropertyName)
+ continue;
+
+ // Use UTF8 lookup to avoid string allocation
+ if (!metadata.TryGetPropertyUtf8(ref reader, out var propInfo) || propInfo == null)
+ {
+ if (!reader.Read()) break;
+ reader.Skip();
+ continue;
+ }
+
+ if (!reader.Read())
+ break;
+
+ var tokenType = reader.TokenType;
+
+ if (maxDepthReached)
+ {
+ if (tokenType != JsonTokenType.StartObject && tokenType != JsonTokenType.StartArray)
+ {
+ // Try direct set for primitives (no boxing)
+ if (!propInfo.TrySetValueDirect(target, ref reader))
+ {
+ var primitiveValue = ReadPrimitiveFromReaderCached(ref reader, propInfo);
+ propInfo.SetValue(target, primitiveValue);
+ }
+ }
+ else
+ {
+ reader.Skip();
+ }
+ continue;
+ }
+
+ // Handle IId collection merge
+ if (propInfo.IsIIdCollection && tokenType == JsonTokenType.StartArray)
+ {
+ var existingCollection = propInfo.GetValue(target);
+ if (existingCollection != null)
+ {
+ MergeIIdCollectionFromReader(ref reader, existingCollection, propInfo, maxDepth, depth);
+ continue;
+ }
+ }
+
+ // Handle nested objects - merge into existing
+ if (tokenType == JsonTokenType.StartObject && !propInfo.PropertyType.IsPrimitive && !ReferenceEquals(propInfo.PropertyType, StringType))
+ {
+ var existingObj = propInfo.GetValue(target);
+ if (existingObj != null)
+ {
+ var nestedMetadata = GetTypeMetadata(propInfo.PropertyType);
+ PopulateObjectMergeFromReader(ref reader, existingObj, nestedMetadata, maxDepth, nextDepth);
+ continue;
+ }
+ }
+
+ // Try direct set for primitives (no boxing)
+ if (propInfo.TrySetValueDirect(target, ref reader))
+ continue;
+
+ // Fallback to boxed path for complex types
+ var value = ReadValueFromReaderCached(ref reader, propInfo, maxDepth, nextDepth);
+ propInfo.SetValue(target, value);
+ }
+ }
+
+ ///
+ /// Read primitive value from Utf8JsonReader using cached PropertySetterInfo.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static object? ReadPrimitiveFromReaderCached(ref Utf8JsonReader reader, PropertySetterInfo propInfo)
+ {
+ var tokenType = reader.TokenType;
+ if (tokenType == JsonTokenType.Null) return null;
+ if (tokenType == JsonTokenType.True) return true;
+ if (tokenType == JsonTokenType.False) return false;
+ if (tokenType == JsonTokenType.Number) return ReadNumberFromReaderCached(ref reader, propInfo);
+ if (tokenType == JsonTokenType.String) return ReadStringFromReaderCached(ref reader, propInfo);
+ return null;
+ }
+
+ ///
+ /// Merge IId collection from Utf8JsonReader streaming.
+ ///
+ private static void MergeIIdCollectionFromReader(ref Utf8JsonReader reader, object existingCollection, PropertySetterInfo propInfo, byte maxDepth, int depth)
+ {
+ var elementType = propInfo.ElementType!;
+ var idGetter = propInfo.ElementIdGetter!;
+ var idType = propInfo.ElementIdType!;
+
+ var existingList = (IList)existingCollection;
+ var count = existingList.Count;
+
+ var acObservable = existingList as IAcObservableCollection;
+ acObservable?.BeginUpdate();
+
+ try
+ {
+ Dictionary? existingById = null;
+ if (count > 0)
+ {
+ existingById = new Dictionary(count);
+ for (var i = 0; i < count; i++)
+ {
+ var item = existingList[i];
+ if (item != null)
+ {
+ var id = idGetter(item);
+ if (id != null && !IsDefaultValue(id, idType))
+ existingById[id] = item;
+ }
+ }
+ }
+
+ var nextDepth = depth + 1;
+ var elementMetadata = GetTypeMetadata(elementType);
+
+ while (reader.Read())
+ {
+ if (reader.TokenType == JsonTokenType.EndArray)
+ break;
+
+ if (reader.TokenType != JsonTokenType.StartObject)
+ {
+ var primitiveValue = ReadValueFromReader(ref reader, elementType, maxDepth, nextDepth);
+ if (primitiveValue != null) existingList.Add(primitiveValue);
+ continue;
+ }
+
+ // For objects, we need to read the full object to check the Id
+ var newItem = ReadObjectFromReaderAtStart(ref reader, elementType, elementMetadata, maxDepth, nextDepth);
+ if (newItem == null) continue;
+
+ // Check if this item already exists by Id
+ var itemId = propInfo.ElementIdGetter?.Invoke(newItem);
+ if (itemId != null && !IsDefaultValue(itemId, idType) && existingById != null)
+ {
+ if (existingById.TryGetValue(itemId, out var existingItem))
+ {
+ // Copy properties from newItem to existingItem
+ CopyProperties(newItem, existingItem, elementMetadata);
+ continue;
+ }
+ }
+
+ existingList.Add(newItem);
+ }
+ }
+ finally { acObservable?.EndUpdate(); }
+ }
+
+ ///
+ /// Copy properties from source to target using metadata.
+ ///
+ private static void CopyProperties(object source, object target, DeserializeTypeMetadata metadata)
+ {
+ foreach (var prop in metadata.PropertySettersFrozen.Values)
+ {
+ var value = prop.GetValue(source);
+ if (value != null)
+ prop.SetValue(target, value);
+ }
+ }
+
+ ///
+ /// Read primitive value from Utf8JsonReader.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static object? ReadPrimitiveFromReader(ref Utf8JsonReader reader, Type targetType)
+ {
+ var tokenType = reader.TokenType;
+ if (tokenType == JsonTokenType.Null) return null;
+ if (tokenType == JsonTokenType.True) return true;
+ if (tokenType == JsonTokenType.False) return false;
+ if (tokenType == JsonTokenType.Number) return ReadNumberFromReader(ref reader, targetType);
+ if (tokenType == JsonTokenType.String) return ReadStringFromReader(ref reader, targetType);
+ return null;
+ }
+
+ #endregion
+}
diff --git a/AyCode.Core/Serializers/Jsons/AcJsonDeserializer.cs b/AyCode.Core/Serializers/Jsons/AcJsonDeserializer.cs
index b5de4f2..5604ae8 100644
--- a/AyCode.Core/Serializers/Jsons/AcJsonDeserializer.cs
+++ b/AyCode.Core/Serializers/Jsons/AcJsonDeserializer.cs
@@ -1,13 +1,10 @@
using System.Collections;
-using System.Collections.Concurrent;
-using System.Collections.Frozen;
-using System.Globalization;
using System.Linq.Expressions;
-using System.Reflection;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using AyCode.Core.Helpers;
+using AyCode.Core.Serializers.Expressions;
using static AyCode.Core.Helpers.JsonUtilities;
namespace AyCode.Core.Serializers.Jsons;
@@ -32,13 +29,12 @@ public class AcJsonDeserializationException : Exception
/// High-performance custom JSON deserializer optimized for IId<T> reference handling.
/// Uses Utf8JsonReader for streaming deserialization when possible (STJ approach).
///
-public static class AcJsonDeserializer
+public static partial class AcJsonDeserializer
{
- private static readonly ConcurrentDictionary TypeMetadataCache = new();
-
// Pre-computed JSON property names for fast lookup (UTF-8 bytes)
private static readonly byte[] RefPropertyUtf8 = "$ref"u8.ToArray();
private static readonly byte[] IdPropertyUtf8 = "$id"u8.ToArray();
+ private static readonly Type ExpressionBaseType = typeof(Expression);
#region Public API
@@ -50,27 +46,28 @@ public static class AcJsonDeserializer
///
/// Deserialize UTF-8 encoded JSON bytes to a new object of type T with default options.
- /// Zero-allocation path when used with Utf8JsonReader.
///
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static T? Deserialize(ReadOnlySpan utf8Json) => Deserialize(utf8Json, AcJsonSerializerOptions.Default);
///
/// Deserialize UTF-8 encoded JSON bytes to a new object of type T with specified options.
- /// Zero-allocation path when used with Utf8JsonReader.
///
public static T? Deserialize(ReadOnlySpan utf8Json, in AcJsonSerializerOptions options)
{
if (utf8Json.IsEmpty) return default;
-
- // Check for "null" literal
if (utf8Json.Length == 4 && utf8Json.SequenceEqual("null"u8)) return default;
var targetType = typeof(T);
+ // Handle Expression types
+ if (IsExpressionType(targetType))
+ {
+ return (T?)(object?)DeserializeExpression(utf8Json, targetType, options);
+ }
+
try
{
- // Fast path for no reference handling - use Utf8JsonReader directly (no string allocation)
if (!options.UseReferenceHandling)
{
var reader = new Utf8JsonReader(utf8Json, new JsonReaderOptions { MaxDepth = options.MaxDepth });
@@ -78,7 +75,6 @@ public static class AcJsonDeserializer
return (T?)ReadValueFromReader(ref reader, targetType, options.MaxDepth, 0);
}
- // Reference handling requires DOM - copy to array for JsonDocument.Parse
var jsonBytes = utf8Json.ToArray();
using var doc = JsonDocument.Parse(jsonBytes);
var context = DeserializationContextPool.Get(options);
@@ -104,59 +100,6 @@ public static class AcJsonDeserializer
}
}
- ///
- /// Deserialize UTF-8 encoded JSON bytes to specified type with default options.
- ///
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- public static object? Deserialize(ReadOnlySpan utf8Json, Type targetType)
- => Deserialize(utf8Json, targetType, AcJsonSerializerOptions.Default);
-
- ///
- /// Deserialize UTF-8 encoded JSON bytes to specified type with specified options.
- ///
- public static object? Deserialize(ReadOnlySpan utf8Json, in Type targetType, in AcJsonSerializerOptions options)
- {
- if (utf8Json.IsEmpty) return null;
-
- // Check for "null" literal
- if (utf8Json.Length == 4 && utf8Json.SequenceEqual("null"u8)) return null;
-
- try
- {
- // Fast path for no reference handling
- if (!options.UseReferenceHandling)
- {
- var reader = new Utf8JsonReader(utf8Json, new JsonReaderOptions { MaxDepth = options.MaxDepth });
- if (!reader.Read()) return null;
- return ReadValueFromReader(ref reader, targetType, options.MaxDepth, 0);
- }
-
- // Reference handling requires DOM - copy to array for JsonDocument.Parse
- var jsonBytes = utf8Json.ToArray();
- using var doc = JsonDocument.Parse(jsonBytes);
- var context = DeserializationContextPool.Get(options);
- try
- {
- var result = ReadValue(doc.RootElement, targetType, context, 0);
- context.ResolveReferences();
- return result;
- }
- finally
- {
- DeserializationContextPool.Return(context);
- }
- }
- catch (AcJsonDeserializationException) { throw; }
- catch (System.Text.Json.JsonException ex)
- {
- throw new AcJsonDeserializationException($"Failed to parse JSON for type '{targetType.Name}': {ex.Message}", null, targetType, ex);
- }
- catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException)
- {
- throw new AcJsonDeserializationException($"Failed to convert JSON value for type '{targetType.Name}': {ex.Message}", null, targetType, ex);
- }
- }
-
///
/// Deserialize JSON string to a new object of type T with specified options.
///
@@ -166,6 +109,20 @@ public static class AcJsonDeserializer
var targetType = typeof(T);
+ // Handle Expression types
+ if (IsExpressionType(targetType))
+ {
+ var (buffer, length) = GetUtf8Bytes(json);
+ try
+ {
+ return (T?)(object?)DeserializeExpression(buffer.AsSpan(0, length), targetType, options);
+ }
+ finally
+ {
+ ReturnUtf8Buffer(buffer);
+ }
+ }
+
try
{
if (TryDeserializePrimitiveFast(json, targetType, out var primitiveResult))
@@ -173,13 +130,11 @@ public static class AcJsonDeserializer
ValidateJson(json, targetType);
- // Fast path for no reference handling - use Utf8JsonReader (streaming, no DOM)
if (!options.UseReferenceHandling)
{
return DeserializeWithUtf8Reader(json, options.MaxDepth);
}
- // Reference handling requires DOM for forward references
using var doc = JsonDocument.Parse(json);
var context = DeserializationContextPool.Get(options);
try
@@ -204,12 +159,6 @@ public static class AcJsonDeserializer
}
}
- ///
- /// Deserialize JSON string to specified type with default options.
- ///
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- public static object? Deserialize(string json, Type targetType) => Deserialize(json, targetType, AcJsonSerializerOptions.Default);
-
///
/// Deserialize JSON string to specified type with specified options.
///
@@ -217,6 +166,20 @@ public static class AcJsonDeserializer
{
if (string.IsNullOrEmpty(json) || json == "null") return null;
+ // Handle Expression types - deserialize as AcExpressionNode and rebuild
+ if (AcSerializerCommon.IsExpressionType(targetType))
+ {
+ var (buffer, length) = GetUtf8Bytes(json);
+ try
+ {
+ return DeserializeExpression(buffer.AsSpan(0, length), targetType, options);
+ }
+ finally
+ {
+ ReturnUtf8Buffer(buffer);
+ }
+ }
+
try
{
var firstChar = json[0];
@@ -263,6 +226,97 @@ public static class AcJsonDeserializer
}
}
+ ///
+ /// Deserialize Expression from JSON.
+ /// Uses AcSerializerCommon for entity type extraction.
+ ///
+ private static Expression? DeserializeExpression(ReadOnlySpan utf8Json, Type targetExpressionType, in AcJsonSerializerOptions options)
+ {
+ var reader = new Utf8JsonReader(utf8Json, new JsonReaderOptions { MaxDepth = options.MaxDepth });
+ if (!reader.Read()) return null;
+
+ var node = (AcExpressionNode?)ReadValueFromReader(ref reader, typeof(AcExpressionNode), options.MaxDepth, 0);
+ if (node == null) return null;
+
+ var entityType = AcSerializerCommon.GetExpressionEntityType(targetExpressionType);
+ return AcExpressionRebuilder.FromNode(node, entityType);
+ }
+
+ ///
+ /// Checks if a type is an Expression type.
+ /// Uses AcSerializerCommon for consistency.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static bool IsExpressionType(Type type)
+ {
+ return AcSerializerCommon.IsExpressionType(type);
+ }
+
+ ///
+ /// Deserialize UTF-8 encoded JSON bytes to specified type with default options.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static object? Deserialize(ReadOnlySpan utf8Json, Type targetType)
+ => Deserialize(utf8Json, targetType, AcJsonSerializerOptions.Default);
+
+ ///
+ /// Deserialize UTF-8 encoded JSON bytes to specified type with specified options.
+ ///
+ public static object? Deserialize(ReadOnlySpan utf8Json, in Type targetType, in AcJsonSerializerOptions options)
+ {
+ if (utf8Json.IsEmpty) return null;
+
+ // Check for "null" literal
+ if (utf8Json.Length == 4 && utf8Json.SequenceEqual("null"u8)) return null;
+
+ // Handle Expression types
+ if (AcSerializerCommon.IsExpressionType(targetType))
+ {
+ return DeserializeExpression(utf8Json, targetType, options);
+ }
+
+ try
+ {
+ // Fast path for no reference handling
+ if (!options.UseReferenceHandling)
+ {
+ var reader = new Utf8JsonReader(utf8Json, new JsonReaderOptions { MaxDepth = options.MaxDepth });
+ if (!reader.Read()) return null;
+ return ReadValueFromReader(ref reader, targetType, options.MaxDepth, 0);
+ }
+
+ // Reference handling requires DOM - copy to array for JsonDocument.Parse
+ var jsonBytes = utf8Json.ToArray();
+ using var doc = JsonDocument.Parse(jsonBytes);
+ var context = DeserializationContextPool.Get(options);
+ try
+ {
+ var result = ReadValue(doc.RootElement, targetType, context, 0);
+ context.ResolveReferences();
+ return result;
+ }
+ finally
+ {
+ DeserializationContextPool.Return(context);
+ }
+ }
+ catch (AcJsonDeserializationException) { throw; }
+ catch (System.Text.Json.JsonException ex)
+ {
+ throw new AcJsonDeserializationException($"Failed to parse JSON for type '{targetType.Name}': {ex.Message}", null, targetType, ex);
+ }
+ catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException)
+ {
+ throw new AcJsonDeserializationException($"Failed to convert JSON value for type '{targetType.Name}': {ex.Message}", null, targetType, ex);
+ }
+ }
+
+ ///
+ /// Deserialize JSON string to specified type with default options.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static object? Deserialize(string json, Type targetType) => Deserialize(json, targetType, AcJsonSerializerOptions.Default);
+
///
/// Populate existing object with JSON data (merge mode) with default options.
///
@@ -389,629 +443,6 @@ public static class AcJsonDeserializer
#endregion
- #region Utf8JsonReader Fast Path (STJ-style streaming)
-
- ///
- /// Deserialize using Utf8JsonReader - streaming without DOM allocation.
- ///
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- private static T? DeserializeWithUtf8Reader(string json, byte maxDepth)
- {
- var (buffer, length) = GetUtf8Bytes(json);
- try
- {
- var reader = new Utf8JsonReader(buffer.AsSpan(0, length), new JsonReaderOptions { MaxDepth = maxDepth });
- if (!reader.Read()) return default;
- return (T?)ReadValueFromReader(ref reader, typeof(T), maxDepth, 0);
- }
- finally
- {
- ReturnUtf8Buffer(buffer);
- }
- }
-
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- private static object? DeserializeWithUtf8ReaderNonGeneric(string json, Type targetType, byte maxDepth)
- {
- var (buffer, length) = GetUtf8Bytes(json);
- try
- {
- var reader = new Utf8JsonReader(buffer.AsSpan(0, length), new JsonReaderOptions { MaxDepth = maxDepth });
- if (!reader.Read()) return null;
- return ReadValueFromReader(ref reader, targetType, maxDepth, 0);
- }
- finally
- {
- ReturnUtf8Buffer(buffer);
- }
- }
-
- private static object? ReadValueFromReader(ref Utf8JsonReader reader, Type targetType, byte maxDepth, int depth)
- {
- switch (reader.TokenType)
- {
- case JsonTokenType.Null:
- return null;
- case JsonTokenType.True:
- return true;
- case JsonTokenType.False:
- return false;
- case JsonTokenType.Number:
- return ReadNumberFromReader(ref reader, targetType);
- case JsonTokenType.String:
- return ReadStringFromReader(ref reader, targetType);
- case JsonTokenType.StartObject:
- return ReadObjectFromReader(ref reader, targetType, maxDepth, depth);
- case JsonTokenType.StartArray:
- return ReadArrayFromReader(ref reader, targetType, maxDepth, depth);
- default:
- return null;
- }
- }
-
- ///
- /// Read number value using cached PropertySetterInfo (avoids Nullable.GetUnderlyingType + Type.GetTypeCode calls).
- ///
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- private static object? ReadNumberFromReaderCached(ref Utf8JsonReader reader, PropertySetterInfo propInfo)
- {
- return propInfo.PropertyTypeCode switch
- {
- TypeCode.Int32 => reader.GetInt32(),
- TypeCode.Int64 => reader.GetInt64(),
- TypeCode.Double => reader.GetDouble(),
- TypeCode.Decimal => reader.GetDecimal(),
- TypeCode.Single => reader.GetSingle(),
- TypeCode.Byte => reader.GetByte(),
- TypeCode.Int16 => reader.GetInt16(),
- TypeCode.UInt16 => reader.GetUInt16(),
- TypeCode.UInt32 => reader.GetUInt32(),
- TypeCode.UInt64 => reader.GetUInt64(),
- TypeCode.SByte => reader.GetSByte(),
- _ => propInfo.UnderlyingType.IsEnum ? Enum.ToObject(propInfo.UnderlyingType, reader.GetInt32()) : reader.GetDouble()
- };
- }
-
- ///
- /// Read string value using cached PropertySetterInfo (avoids Nullable.GetUnderlyingType calls).
- ///
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- private static object? ReadStringFromReaderCached(ref Utf8JsonReader reader, PropertySetterInfo propInfo)
- {
- var type = propInfo.UnderlyingType;
-
- if (ReferenceEquals(type, StringType)) return reader.GetString();
- if (ReferenceEquals(type, DateTimeType)) return reader.GetDateTime();
- if (ReferenceEquals(type, GuidType)) return reader.GetGuid();
- if (ReferenceEquals(type, DateTimeOffsetType)) return reader.GetDateTimeOffset();
- if (ReferenceEquals(type, TimeSpanType))
- {
- var str = reader.GetString();
- return str != null ? TimeSpan.Parse(str, CultureInfo.InvariantCulture) : default(TimeSpan);
- }
- if (type.IsEnum)
- {
- var str = reader.GetString();
- return str != null ? Enum.Parse(type, str) : null;
- }
-
- return reader.GetString();
- }
-
- ///
- /// Read value from reader using cached PropertySetterInfo for faster type resolution.
- ///
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- private static object? ReadValueFromReaderCached(ref Utf8JsonReader reader, PropertySetterInfo propInfo, byte maxDepth, int depth)
- {
- switch (reader.TokenType)
- {
- case JsonTokenType.Null:
- return null;
- case JsonTokenType.True:
- return true;
- case JsonTokenType.False:
- return false;
- case JsonTokenType.Number:
- return ReadNumberFromReaderCached(ref reader, propInfo);
- case JsonTokenType.String:
- return ReadStringFromReaderCached(ref reader, propInfo);
- case JsonTokenType.StartObject:
- return ReadObjectFromReader(ref reader, propInfo.PropertyType, maxDepth, depth);
- case JsonTokenType.StartArray:
- return ReadArrayFromReader(ref reader, propInfo.PropertyType, maxDepth, depth);
- default:
- return null;
- }
- }
-
- private static object? ReadNumberFromReader(ref Utf8JsonReader reader, Type targetType)
- {
- var type = Nullable.GetUnderlyingType(targetType) ?? targetType;
- var typeCode = Type.GetTypeCode(type);
-
- return typeCode switch
- {
- TypeCode.Int32 => reader.GetInt32(),
- TypeCode.Int64 => reader.GetInt64(),
- TypeCode.Double => reader.GetDouble(),
- TypeCode.Decimal => reader.GetDecimal(),
- TypeCode.Single => reader.GetSingle(),
- TypeCode.Byte => reader.GetByte(),
- TypeCode.Int16 => reader.GetInt16(),
- TypeCode.UInt16 => reader.GetUInt16(),
- TypeCode.UInt32 => reader.GetUInt32(),
- TypeCode.UInt64 => reader.GetUInt64(),
- TypeCode.SByte => reader.GetSByte(),
- _ => type.IsEnum ? Enum.ToObject(type, reader.GetInt32()) : reader.GetDouble()
- };
- }
-
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- private static object? ReadStringFromReader(ref Utf8JsonReader reader, Type targetType)
- {
- var type = Nullable.GetUnderlyingType(targetType) ?? targetType;
-
- if (ReferenceEquals(type, StringType)) return reader.GetString();
- if (ReferenceEquals(type, DateTimeType)) return reader.GetDateTime();
- if (ReferenceEquals(type, GuidType)) return reader.GetGuid();
- if (ReferenceEquals(type, DateTimeOffsetType)) return reader.GetDateTimeOffset();
- if (ReferenceEquals(type, TimeSpanType))
- {
- var str = reader.GetString();
- return str != null ? TimeSpan.Parse(str, CultureInfo.InvariantCulture) : default(TimeSpan);
- }
- if (type.IsEnum)
- {
- var str = reader.GetString();
- return str != null ? Enum.Parse(type, str) : null;
- }
-
- return reader.GetString();
- }
-
- private static object? ReadObjectFromReader(ref Utf8JsonReader reader, Type targetType, byte maxDepth, int depth)
- {
- if (depth > maxDepth)
- {
- reader.Skip();
- return null;
- }
-
- if (IsDictionaryType(targetType, out var keyType, out var valueType))
- return ReadDictionaryFromReader(ref reader, keyType!, valueType!, maxDepth, depth);
-
- var metadata = GetTypeMetadata(targetType);
-
- var instance = metadata.CompiledConstructor?.Invoke();
- if (instance == null)
- {
- try { instance = Activator.CreateInstance(targetType); }
- catch (MissingMethodException ex)
- {
- throw new AcJsonDeserializationException(
- $"Cannot deserialize type '{targetType.FullName}' because it does not have a parameterless constructor.",
- null, targetType, ex);
- }
- }
-
- if (instance == null) return null;
-
- var nextDepth = depth + 1;
-
- while (reader.Read())
- {
- if (reader.TokenType == JsonTokenType.EndObject)
- break;
-
- if (reader.TokenType != JsonTokenType.PropertyName)
- continue;
-
- // Use UTF8 lookup to avoid string allocation
- if (!metadata.TryGetPropertyUtf8(ref reader, out var propInfo) || propInfo == null)
- {
- if (!reader.Read()) break;
- reader.Skip();
- continue;
- }
-
- if (!reader.Read())
- break;
-
- // Try direct set for primitives (no boxing)
- if (propInfo.TrySetValueDirect(instance, ref reader))
- continue;
-
- // Fallback to boxed path for complex types
- var value = ReadValueFromReaderCached(ref reader, propInfo, maxDepth, nextDepth);
- propInfo.SetValue(instance, value);
- }
-
- return instance;
- }
-
- ///
- /// Read object from reader when we're already at StartObject.
- ///
- private static object? ReadObjectFromReaderAtStart(ref Utf8JsonReader reader, Type targetType, DeserializeTypeMetadata metadata, byte maxDepth, int depth)
- {
- if (depth > maxDepth)
- {
- reader.Skip();
- return null;
- }
-
- var instance = metadata.CompiledConstructor?.Invoke();
- if (instance == null)
- {
- try { instance = Activator.CreateInstance(targetType); }
- catch (MissingMethodException ex)
- {
- throw new AcJsonDeserializationException(
- $"Cannot deserialize type '{targetType.FullName}' because it does not have a parameterless constructor.",
- null, targetType, ex);
- }
- }
-
- if (instance == null) return null;
-
- var nextDepth = depth + 1;
-
- while (reader.Read())
- {
- if (reader.TokenType == JsonTokenType.EndObject)
- break;
-
- if (reader.TokenType != JsonTokenType.PropertyName)
- continue;
-
- // Use UTF8 lookup to avoid string allocation
- if (!metadata.TryGetPropertyUtf8(ref reader, out var propInfo) || propInfo == null)
- {
- if (!reader.Read()) break;
- reader.Skip();
- continue;
- }
-
- if (!reader.Read())
- break;
-
- // Try direct set for primitives (no boxing)
- if (propInfo.TrySetValueDirect(instance, ref reader))
- continue;
-
- // Fallback to boxed path for complex types
- var value = ReadValueFromReaderCached(ref reader, propInfo, maxDepth, nextDepth);
- propInfo.SetValue(instance, value);
- }
-
- return instance;
- }
-
- private static object? ReadArrayFromReader(ref Utf8JsonReader reader, Type targetType, byte maxDepth, int depth)
- {
- if (depth > maxDepth)
- {
- reader.Skip();
- return null;
- }
-
- var elementType = GetCollectionElementType(targetType);
- if (elementType == null) return null;
-
- var nextDepth = depth + 1;
- var list = GetOrCreateListFactory(elementType)();
-
- while (reader.Read())
- {
- if (reader.TokenType == JsonTokenType.EndArray)
- break;
-
- var value = ReadValueFromReader(ref reader, elementType, maxDepth, nextDepth);
- if (value != null)
- list.Add(value);
- }
-
- if (targetType.IsArray)
- {
- var array = Array.CreateInstance(elementType, list.Count);
- list.CopyTo(array, 0);
- return array;
- }
-
- return list;
- }
-
- private static object ReadDictionaryFromReader(ref Utf8JsonReader reader, Type keyType, Type valueType, byte maxDepth, int depth)
- {
- var dictType = DictionaryGenericType.MakeGenericType(keyType, valueType);
- var dict = (IDictionary)Activator.CreateInstance(dictType)!;
- var nextDepth = depth + 1;
-
- while (reader.Read())
- {
- if (reader.TokenType == JsonTokenType.EndObject)
- break;
-
- if (reader.TokenType != JsonTokenType.PropertyName)
- continue;
-
- var keyStr = reader.GetString();
- if (keyStr == null || !reader.Read())
- continue;
-
- object key;
- if (ReferenceEquals(keyType, StringType)) key = keyStr;
- else if (ReferenceEquals(keyType, IntType)) key = int.Parse(keyStr, CultureInfo.InvariantCulture);
- else if (ReferenceEquals(keyType, LongType)) key = long.Parse(keyStr, CultureInfo.InvariantCulture);
- else if (ReferenceEquals(keyType, GuidType)) key = Guid.Parse(keyStr);
- else if (keyType.IsEnum) key = Enum.Parse(keyType, keyStr);
- else key = Convert.ChangeType(keyStr, keyType, CultureInfo.InvariantCulture);
-
- var value = ReadValueFromReader(ref reader, valueType, maxDepth, nextDepth);
- dict.Add(key, value);
- }
-
- return dict;
- }
-
- ///
- /// Populate object using Utf8JsonReader streaming (no DOM allocation).
- ///
- private static void PopulateObjectWithUtf8Reader(string json, object target, DeserializeTypeMetadata metadata, byte maxDepth)
- {
- var (buffer, length) = GetUtf8Bytes(json);
- try
- {
- var reader = new Utf8JsonReader(buffer.AsSpan(0, length), new JsonReaderOptions { MaxDepth = maxDepth });
-
- if (!reader.Read() || reader.TokenType != JsonTokenType.StartObject)
- return;
-
- PopulateObjectMergeFromReader(ref reader, target, metadata, maxDepth, 0);
- }
- finally
- {
- ReturnUtf8Buffer(buffer);
- }
- }
-
- ///
- /// Populate list using Utf8JsonReader streaming (no DOM allocation).
- ///
- private static void PopulateListWithUtf8Reader(string json, IList targetList, in Type listType, byte maxDepth)
- {
- var (buffer, length) = GetUtf8Bytes(json);
- try
- {
- var reader = new Utf8JsonReader(buffer.AsSpan(0, length), new JsonReaderOptions { MaxDepth = maxDepth });
-
- if (!reader.Read() || reader.TokenType != JsonTokenType.StartArray)
- return;
-
- var elementType = GetCollectionElementType(listType);
- if (elementType == null) return;
-
- var acObservable = targetList as IAcObservableCollection;
- acObservable?.BeginUpdate();
-
- try
- {
- targetList.Clear();
-
- while (reader.Read())
- {
- if (reader.TokenType == JsonTokenType.EndArray)
- break;
-
- var value = ReadValueFromReader(ref reader, elementType, maxDepth, 1);
- if (value != null)
- targetList.Add(value);
- }
- }
- finally { acObservable?.EndUpdate(); }
- }
- finally
- {
- ReturnUtf8Buffer(buffer);
- }
- }
-
- ///
- /// Populate object with merge semantics from Utf8JsonReader.
- ///
- private static void PopulateObjectMergeFromReader(ref Utf8JsonReader reader, object target, DeserializeTypeMetadata metadata, byte maxDepth, int depth)
- {
- var nextDepth = depth + 1;
- var maxDepthReached = nextDepth > maxDepth;
-
- while (reader.Read())
- {
- if (reader.TokenType == JsonTokenType.EndObject)
- break;
-
- if (reader.TokenType != JsonTokenType.PropertyName)
- continue;
-
- // Use UTF8 lookup to avoid string allocation
- if (!metadata.TryGetPropertyUtf8(ref reader, out var propInfo) || propInfo == null)
- {
- if (!reader.Read()) break;
- reader.Skip();
- continue;
- }
-
- if (!reader.Read())
- break;
-
- var tokenType = reader.TokenType;
-
- if (maxDepthReached)
- {
- if (tokenType != JsonTokenType.StartObject && tokenType != JsonTokenType.StartArray)
- {
- // Try direct set for primitives (no boxing)
- if (!propInfo.TrySetValueDirect(target, ref reader))
- {
- var primitiveValue = ReadPrimitiveFromReaderCached(ref reader, propInfo);
- propInfo.SetValue(target, primitiveValue);
- }
- }
- else
- {
- reader.Skip();
- }
- continue;
- }
-
- // Handle IId collection merge
- if (propInfo.IsIIdCollection && tokenType == JsonTokenType.StartArray)
- {
- var existingCollection = propInfo.GetValue(target);
- if (existingCollection != null)
- {
- MergeIIdCollectionFromReader(ref reader, existingCollection, propInfo, maxDepth, depth);
- continue;
- }
- }
-
- // Handle nested objects - merge into existing
- if (tokenType == JsonTokenType.StartObject && !propInfo.PropertyType.IsPrimitive && !ReferenceEquals(propInfo.PropertyType, StringType))
- {
- var existingObj = propInfo.GetValue(target);
- if (existingObj != null)
- {
- var nestedMetadata = GetTypeMetadata(propInfo.PropertyType);
- PopulateObjectMergeFromReader(ref reader, existingObj, nestedMetadata, maxDepth, nextDepth);
- continue;
- }
- }
-
- // Try direct set for primitives (no boxing)
- if (propInfo.TrySetValueDirect(target, ref reader))
- continue;
-
- // Fallback to boxed path for complex types
- var value = ReadValueFromReaderCached(ref reader, propInfo, maxDepth, nextDepth);
- propInfo.SetValue(target, value);
- }
- }
-
- ///
- /// Read primitive value from Utf8JsonReader using cached PropertySetterInfo.
- ///
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- private static object? ReadPrimitiveFromReaderCached(ref Utf8JsonReader reader, PropertySetterInfo propInfo)
- {
- var tokenType = reader.TokenType;
- if (tokenType == JsonTokenType.Null) return null;
- if (tokenType == JsonTokenType.True) return true;
- if (tokenType == JsonTokenType.False) return false;
- if (tokenType == JsonTokenType.Number) return ReadNumberFromReaderCached(ref reader, propInfo);
- if (tokenType == JsonTokenType.String) return ReadStringFromReaderCached(ref reader, propInfo);
- return null;
- }
-
- ///
- /// Merge IId collection from Utf8JsonReader streaming.
- ///
- private static void MergeIIdCollectionFromReader(ref Utf8JsonReader reader, object existingCollection, PropertySetterInfo propInfo, byte maxDepth, int depth)
- {
- var elementType = propInfo.ElementType!;
- var idGetter = propInfo.ElementIdGetter!;
- var idType = propInfo.ElementIdType!;
-
- var existingList = (IList)existingCollection;
- var count = existingList.Count;
-
- var acObservable = existingList as IAcObservableCollection;
- acObservable?.BeginUpdate();
-
- try
- {
- Dictionary? existingById = null;
- if (count > 0)
- {
- existingById = new Dictionary(count);
- for (var i = 0; i < count; i++)
- {
- var item = existingList[i];
- if (item != null)
- {
- var id = idGetter(item);
- if (id != null && !IsDefaultValue(id, idType))
- existingById[id] = item;
- }
- }
- }
-
- var nextDepth = depth + 1;
- var elementMetadata = GetTypeMetadata(elementType);
-
- while (reader.Read())
- {
- if (reader.TokenType == JsonTokenType.EndArray)
- break;
-
- if (reader.TokenType != JsonTokenType.StartObject)
- {
- var primitiveValue = ReadValueFromReader(ref reader, elementType, maxDepth, nextDepth);
- if (primitiveValue != null) existingList.Add(primitiveValue);
- continue;
- }
-
- // For objects, we need to read the full object to check the Id
- var newItem = ReadObjectFromReaderAtStart(ref reader, elementType, elementMetadata, maxDepth, nextDepth);
- if (newItem == null) continue;
-
- // Check if this item already exists by Id
- var itemId = propInfo.ElementIdGetter?.Invoke(newItem);
- if (itemId != null && !IsDefaultValue(itemId, idType) && existingById != null)
- {
- if (existingById.TryGetValue(itemId, out var existingItem))
- {
- // Copy properties from newItem to existingItem
- CopyProperties(newItem, existingItem, elementMetadata);
- continue;
- }
- }
-
- existingList.Add(newItem);
- }
- }
- finally { acObservable?.EndUpdate(); }
- }
-
- ///
- /// Copy properties from source to target using metadata.
- ///
- private static void CopyProperties(object source, object target, DeserializeTypeMetadata metadata)
- {
- foreach (var prop in metadata.PropertySettersFrozen.Values)
- {
- var value = prop.GetValue(source);
- if (value != null)
- prop.SetValue(target, value);
- }
- }
-
- ///
- /// Read primitive value from Utf8JsonReader.
- ///
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- private static object? ReadPrimitiveFromReader(ref Utf8JsonReader reader, Type targetType)
- {
- var tokenType = reader.TokenType;
- if (tokenType == JsonTokenType.Null) return null;
- if (tokenType == JsonTokenType.True) return true;
- if (tokenType == JsonTokenType.False) return false;
- if (tokenType == JsonTokenType.Number) return ReadNumberFromReader(ref reader, targetType);
- if (tokenType == JsonTokenType.String) return ReadStringFromReader(ref reader, targetType);
- return null;
- }
-
- #endregion
-
#region Validation
[MethodImpl(MethodImplOptions.AggressiveInlining)]
@@ -1053,828 +484,267 @@ public static class AcJsonDeserializer
#endregion
- #region With Reference Handling (JsonElement Path)
+ #region Chain API
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- private static object? ReadValue(in JsonElement element, in Type targetType, DeserializationContext context, int depth)
+ ///
+ /// Create a deserialize chain that parses JSON once and allows multiple deserializations.
+ /// Efficient for deserializing the same JSON to multiple different types.
+ ///
+ public static IDeserializeChain CreateDeserializeChain(string json)
+ => CreateDeserializeChain(json, AcJsonSerializerOptions.Default);
+
+ ///
+ /// Create a deserialize chain with options.
+ ///
+ public static IDeserializeChain CreateDeserializeChain(string json, in AcJsonSerializerOptions options)
{
- var kind = element.ValueKind;
- if (kind == JsonValueKind.Object) return ReadObject(element, targetType, context, depth);
- if (kind == JsonValueKind.Array) return ReadArray(element, targetType, context, depth);
- if (kind == JsonValueKind.Null || kind == JsonValueKind.Undefined) return null;
- return ReadPrimitive(element, targetType, kind);
+ if (string.IsNullOrEmpty(json) || json == "null")
+ return DeserializeChain.Empty;
+
+ var targetType = typeof(T);
+ ValidateJson(json, targetType);
+
+ var doc = JsonDocument.Parse(json);
+ var context = DeserializationContextPool.Get(options);
+
+ try
+ {
+ var result = ReadValue(doc.RootElement, targetType, context, 0);
+ context.ResolveReferences();
+ return new DeserializeChain(doc, context, options, (T?)result);
+ }
+ catch
+ {
+ DeserializationContextPool.Return(context);
+ doc.Dispose();
+ throw;
+ }
}
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- private static object? ReadObject(in JsonElement element, in Type targetType, DeserializationContext context, int depth)
+ ///
+ /// Create a populate chain that parses JSON once and allows populating multiple objects.
+ /// Efficient for populating multiple objects from the same JSON source.
+ ///
+ public static IPopulateChain CreatePopulateChain(string json, object target)
+ => CreatePopulateChain(json, target, AcJsonSerializerOptions.Default);
+
+ ///
+ /// Create a populate chain with options.
+ ///
+ public static IPopulateChain CreatePopulateChain(string json, object target, in AcJsonSerializerOptions options)
{
- // Check for $ref first
- if (element.TryGetProperty(RefPropertyUtf8, out var refElement))
+ ArgumentNullException.ThrowIfNull(target);
+
+ if (string.IsNullOrEmpty(json) || json == "null")
+ return PopulateChain.Empty;
+
+ var targetType = target.GetType();
+ ValidateJson(json, targetType);
+
+ var doc = JsonDocument.Parse(json);
+ var context = DeserializationContextPool.Get(options);
+ context.IsMergeMode = true;
+
+ try
{
- var refId = refElement.GetString()!;
- return context.TryGetReferencedObject(refId, out var refObj) ? refObj : new DeferredReference(refId, targetType);
+ PopulateFromDocument(doc.RootElement, target, targetType, context);
+ context.ResolveReferences();
+ return new PopulateChain(doc, context, options);
+ }
+ catch
+ {
+ DeserializationContextPool.Return(context);
+ doc.Dispose();
+ throw;
+ }
+ }
+
+ ///
+ /// Internal helper: populate object from parsed JsonDocument.
+ /// Reuses existing populate logic without reparsing.
+ ///
+ private static void PopulateFromDocument(in JsonElement rootElement, object target, in Type targetType, DeserializationContext context)
+ {
+ if (rootElement.ValueKind == JsonValueKind.Array)
+ {
+ if (target is IList targetList)
+ PopulateList(rootElement, targetList, targetType, context, 0);
+ else
+ throw new AcJsonDeserializationException($"Cannot populate non-list target '{targetType.Name}' with JSON array", null, targetType);
+ }
+ else if (rootElement.ValueKind == JsonValueKind.Object)
+ {
+ var metadata = GetTypeMetadata(targetType);
+ PopulateObjectInternalMerge(rootElement, target, metadata, context, 0);
+ }
+ else
+ throw new AcJsonDeserializationException($"Cannot populate object with JSON value of kind '{rootElement.ValueKind}'", null, targetType);
+ }
+
+ #endregion
+
+ #region Chain Implementations (Nested Classes)
+
+ ///
+ /// Implementation of deserialize chain.
+ ///
+ private sealed class DeserializeChain : IDeserializeChain
+ {
+ public static readonly IDeserializeChain Empty = new EmptyDeserializeChain();
+
+ private JsonDocument? _document;
+ private DeserializationContext? _context;
+ private readonly AcJsonSerializerOptions _options;
+
+ public T? Value { get; }
+
+ public DeserializeChain(JsonDocument document, DeserializationContext context, AcJsonSerializerOptions options, T? value)
+ {
+ _document = document;
+ _context = context;
+ _options = options;
+ Value = value;
}
- if (depth > context.MaxDepth) return null;
-
- if (IsDictionaryType(targetType, out var keyType, out var valueType))
- return ReadDictionary(element, keyType!, valueType!, context, depth);
-
- var metadata = GetTypeMetadata(targetType);
-
- var instance = metadata.CompiledConstructor?.Invoke();
- if (instance == null)
+ public TOther? ThenDeserialize()
{
- try { instance = Activator.CreateInstance(targetType); }
- catch (MissingMethodException ex)
+ if (_document == null || _context == null)
+ throw new ObjectDisposedException(nameof(DeserializeChain));
+
+ var targetType = typeof(TOther);
+
+ try
+ {
+ var result = ReadValue(_document.RootElement, targetType, _context, 0);
+ _context.ResolveReferences();
+ return (TOther?)result;
+ }
+ catch (AcJsonDeserializationException) { throw; }
+ catch (Exception ex)
{
throw new AcJsonDeserializationException(
- $"Cannot deserialize type '{targetType.FullName}' because it does not have a parameterless constructor.",
+ $"Failed to deserialize to type '{targetType.Name}' in chain: {ex.Message}",
null, targetType, ex);
}
}
-
- if (instance == null) return null;
- // Check for $id and register
- if (element.TryGetProperty(IdPropertyUtf8, out var idElement))
- context.RegisterObject(idElement.GetString()!, instance);
-
- PopulateObjectInternal(element, instance, metadata, context, depth);
-
- return instance;
- }
-
- private static void PopulateObjectInternal(in JsonElement element, object target, DeserializeTypeMetadata metadata, DeserializationContext context, int depth)
- {
- var propsDict = metadata.PropertySettersFrozen;
- var nextDepth = depth + 1;
-
- foreach (var jsonProp in element.EnumerateObject())
+ public void Dispose()
{
- var propName = jsonProp.Name;
-
- // Skip $ properties
- if (propName.Length > 0 && propName[0] == '$') continue;
+ if (_context != null)
+ {
+ DeserializationContextPool.Return(_context);
+ _context = null;
+ }
+ if (_document != null)
+ {
+ _document.Dispose();
+ _document = null;
+ }
+ }
- if (!propsDict.TryGetValue(propName, out var propInfo)) continue;
-
- var value = ReadValue(jsonProp.Value, propInfo.PropertyType, context, nextDepth);
-
- if (value is DeferredReference deferred)
- context.AddPropertyToResolve(target, propInfo, deferred.RefId);
- else
- propInfo.SetValue(target, value);
+ private sealed class EmptyDeserializeChain : IDeserializeChain
+ {
+ public T? Value => default;
+ public TOther? ThenDeserialize() => default;
+ public void Dispose() { }
}
}
- private static void PopulateObjectInternalMerge(in JsonElement element, object target, DeserializeTypeMetadata metadata, DeserializationContext context, int depth)
+ ///
+ /// Implementation of populate chain.
+ ///
+ private sealed class PopulateChain : IPopulateChain
{
- var propsDict = metadata.PropertySettersFrozen;
- var nextDepth = depth + 1;
- var maxDepthReached = nextDepth > context.MaxDepth;
-
- foreach (var jsonProp in element.EnumerateObject())
+ public static readonly IPopulateChain Empty = new EmptyPopulateChain();
+
+ private JsonDocument? _document;
+ private DeserializationContext? _context;
+ private readonly AcJsonSerializerOptions _options;
+
+ public PopulateChain(JsonDocument document, DeserializationContext context, AcJsonSerializerOptions options)
{
- var propName = jsonProp.Name;
-
- // Skip $ properties
- if (propName.Length > 0 && propName[0] == '$') continue;
-
- if (!propsDict.TryGetValue(propName, out var propInfo)) continue;
-
- var propValue = jsonProp.Value;
- var propValueKind = propValue.ValueKind;
-
- if (maxDepthReached)
- {
- if (propValueKind != JsonValueKind.Object && propValueKind != JsonValueKind.Array)
- {
- var primitiveValue = ReadPrimitive(propValue, propInfo.PropertyType, propValueKind);
- propInfo.SetValue(target, primitiveValue);
- }
- continue;
- }
-
- // Handle IId collection merge
- if (propInfo.IsIIdCollection && propValueKind == JsonValueKind.Array)
- {
- var existingCollection = propInfo.GetValue(target);
- if (existingCollection != null)
- {
- MergeIIdCollection(propValue, existingCollection, propInfo, context, depth);
- continue;
- }
- }
-
- // Handle nested objects
- if (propValueKind == JsonValueKind.Object)
- {
- // Check for $ref
- if (propValue.TryGetProperty(RefPropertyUtf8, out _))
- {
- var value = ReadValue(propValue, propInfo.PropertyType, context, nextDepth);
- if (value is DeferredReference deferred)
- context.AddPropertyToResolve(target, propInfo, deferred.RefId);
- else
- propInfo.SetValue(target, value);
- continue;
- }
-
- // Merge into existing object
- if (!propInfo.PropertyType.IsPrimitive && !ReferenceEquals(propInfo.PropertyType, StringType))
- {
- var existingObj = propInfo.GetValue(target);
- if (existingObj != null)
- {
- var nestedMetadata = GetTypeMetadata(propInfo.PropertyType);
- PopulateObjectInternalMerge(propValue, existingObj, nestedMetadata, context, nextDepth);
- continue;
- }
- }
- }
-
- var value2 = ReadValue(propValue, propInfo.PropertyType, context, nextDepth);
-
- if (value2 is DeferredReference deferred2)
- context.AddPropertyToResolve(target, propInfo, deferred2.RefId);
- else
- propInfo.SetValue(target, value2);
+ _document = document;
+ _context = context;
+ _options = options;
}
- }
- private static void PopulateList(in JsonElement arrayElement, IList targetList, in Type listType, DeserializationContext context, int depth)
- {
- if (depth > context.MaxDepth) return;
-
- var elementType = GetCollectionElementType(listType);
- if (elementType == null) return;
-
- var acObservable = targetList as IAcObservableCollection;
- acObservable?.BeginUpdate();
-
- try
+ public IPopulateChain ThenPopulate(object target)
{
- targetList.Clear();
- var nextDepth = depth + 1;
-
- foreach (var item in arrayElement.EnumerateArray())
+ ArgumentNullException.ThrowIfNull(target);
+
+ if (_document == null || _context == null)
+ throw new ObjectDisposedException(nameof(PopulateChain));
+
+ var targetType = target.GetType();
+
+ try
{
- var value = ReadValue(item, elementType, context, nextDepth);
- if (value != null)
- targetList.Add(value);
+ PopulateFromDocument(_document.RootElement, target, targetType, _context);
+ _context.ResolveReferences();
+ return this;
+ }
+ catch (AcJsonDeserializationException) { throw; }
+ catch (Exception ex)
+ {
+ throw new AcJsonDeserializationException(
+ $"Failed to populate object of type '{targetType.Name}' in chain: {ex.Message}",
+ null, targetType, ex);
}
}
- finally { acObservable?.EndUpdate(); }
- }
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- private static object? ReadArray(in JsonElement element, in Type targetType, DeserializationContext context, int depth)
- {
- if (depth > context.MaxDepth) return null;
-
- var elementType = GetCollectionElementType(targetType);
- if (elementType == null) return null;
-
- var nextDepth = depth + 1;
-
- if (targetType.IsArray)
+ public void Dispose()
{
- var list = GetOrCreateListFactory(elementType)();
- foreach (var item in element.EnumerateArray())
- list.Add(ReadValue(item, elementType, context, nextDepth));
-
- var array = Array.CreateInstance(elementType, list.Count);
- list.CopyTo(array, 0);
- return array;
- }
-
- IList? targetList = null;
- try
- {
- var instance = Activator.CreateInstance(targetType);
- if (instance is IList list) targetList = list;
- }
- catch { /* Fallback to List */ }
-
- targetList ??= GetOrCreateListFactory(elementType)();
-
- var acObservable = targetList as IAcObservableCollection;
- acObservable?.BeginUpdate();
-
- try
- {
- foreach (var item in element.EnumerateArray())
- targetList.Add(ReadValue(item, elementType, context, nextDepth));
- }
- finally { acObservable?.EndUpdate(); }
-
- return targetList;
- }
-
- private static void MergeIIdCollection(in JsonElement arrayElement, object existingCollection, PropertySetterInfo propInfo, DeserializationContext context, int depth)
- {
- var elementType = propInfo.ElementType!;
- var idGetter = propInfo.ElementIdGetter!;
- var idType = propInfo.ElementIdType!;
-
- var existingList = (IList)existingCollection;
- var count = existingList.Count;
-
- var acObservable = existingList as IAcObservableCollection;
- acObservable?.BeginUpdate();
-
- try
- {
- Dictionary? existingById = null;
- if (count > 0)
+ if (_context != null)
{
- existingById = new Dictionary(count);
- for (var i = 0; i < count; i++)
- {
- var item = existingList[i];
- if (item != null)
- {
- var id = idGetter(item);
- if (id != null && !IsDefaultValue(id, idType))
- existingById[id] = item;
- }
- }
+ DeserializationContextPool.Return(_context);
+ _context = null;
}
-
- var nextDepth = depth + 1;
- foreach (var jsonItem in arrayElement.EnumerateArray())
+ if (_document != null)
{
- if (jsonItem.ValueKind != JsonValueKind.Object) continue;
-
- object? itemId = null;
- if (jsonItem.TryGetProperty("Id", out var idProp))
- itemId = ReadPrimitive(idProp, idType, idProp.ValueKind);
-
- if (itemId != null && !IsDefaultValue(itemId, idType) && existingById != null)
- {
- if (existingById.TryGetValue(itemId, out var existingItem))
- {
- var itemMetadata = GetTypeMetadata(elementType);
- PopulateObjectInternalMerge(jsonItem, existingItem, itemMetadata, context, nextDepth);
- continue;
- }
- }
-
- var newItem = ReadValue(jsonItem, elementType, context, nextDepth);
- if (newItem != null) existingList.Add(newItem);
+ _document.Dispose();
+ _document = null;
}
}
- finally { acObservable?.EndUpdate(); }
- }
- private static object ReadDictionary(in JsonElement element, in Type keyType, in Type valueType, DeserializationContext context, int depth)
- {
- var dictType = DictionaryGenericType.MakeGenericType(keyType, valueType);
- var dict = (IDictionary)Activator.CreateInstance(dictType)!;
- var nextDepth = depth + 1;
-
- foreach (var prop in element.EnumerateObject())
+ private sealed class EmptyPopulateChain : IPopulateChain
{
- var name = prop.Name;
- if (name.Length > 0 && name[0] == '$') continue;
-
- object key;
- if (ReferenceEquals(keyType, StringType)) key = name;
- else if (ReferenceEquals(keyType, IntType)) key = int.Parse(name, CultureInfo.InvariantCulture);
- else if (ReferenceEquals(keyType, LongType)) key = long.Parse(name, CultureInfo.InvariantCulture);
- else if (ReferenceEquals(keyType, GuidType)) key = Guid.Parse(name);
- else if (keyType.IsEnum) key = Enum.Parse(keyType, name);
- else key = Convert.ChangeType(name, keyType, CultureInfo.InvariantCulture);
-
- dict.Add(key, ReadValue(prop.Value, valueType, context, nextDepth));
- }
-
- return dict;
- }
-
- #endregion
-
- #region Primitive Reading
-
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- private static object? ReadPrimitive(in JsonElement element, in Type targetType, JsonValueKind valueKind)
- {
- var type = Nullable.GetUnderlyingType(targetType) ?? targetType;
-
- if (valueKind == JsonValueKind.Number)
- {
- var typeCode = Type.GetTypeCode(type);
- return typeCode switch
- {
- TypeCode.Int32 => element.GetInt32(),
- TypeCode.Int64 => element.GetInt64(),
- TypeCode.Double => element.GetDouble(),
- TypeCode.Decimal => element.GetDecimal(),
- TypeCode.Single => element.GetSingle(),
- TypeCode.Byte => element.GetByte(),
- TypeCode.Int16 => element.GetInt16(),
- TypeCode.UInt16 => element.GetUInt16(),
- TypeCode.UInt32 => element.GetUInt32(),
- TypeCode.UInt64 => element.GetUInt64(),
- TypeCode.SByte => element.GetSByte(),
- _ => type.IsEnum ? Enum.ToObject(type, element.GetInt32()) : null
- };
- }
-
- 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 (ReferenceEquals(type, DateTimeOffsetType)) return element.GetDateTimeOffset();
- if (ReferenceEquals(type, TimeSpanType)) return TimeSpan.Parse(element.GetString()!, CultureInfo.InvariantCulture);
- if (type.IsEnum) return Enum.Parse(type, element.GetString()!);
- return null;
- }
-
- if (valueKind == JsonValueKind.True) return true;
- if (valueKind == JsonValueKind.False) return false;
-
- return null;
- }
-
- #endregion
-
- #region Primitive Deserialization
-
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- private static bool TryDeserializePrimitiveFast(string json, in Type targetType, out object? result)
- {
- var type = Nullable.GetUnderlyingType(targetType) ?? targetType;
-
- // Handle enums first
- if (type.IsEnum)
- {
- if (json.Length > 0 && json[0] == '"')
- {
- using var doc = JsonDocument.Parse(json);
- result = Enum.Parse(type, doc.RootElement.GetString()!);
- }
- else result = Enum.ToObject(type, int.Parse(json, CultureInfo.InvariantCulture));
- return true;
- }
-
- var typeCode = Type.GetTypeCode(type);
-
- switch (typeCode)
- {
- case TypeCode.Int32: result = int.Parse(json, CultureInfo.InvariantCulture); return true;
- case TypeCode.Int64: result = long.Parse(json, CultureInfo.InvariantCulture); return true;
- case TypeCode.Boolean: result = json == "true"; return true;
- case TypeCode.Double: result = double.Parse(json, CultureInfo.InvariantCulture); return true;
- case TypeCode.Decimal: result = decimal.Parse(json, CultureInfo.InvariantCulture); return true;
- case TypeCode.Single: result = float.Parse(json, CultureInfo.InvariantCulture); return true;
- case TypeCode.String:
- // If already unwrapped (no quotes), return as-is; otherwise parse JSON
- if (json.Length == 0 || json[0] != '"')
- {
- result = json;
- return true;
- }
- using (var doc = JsonDocument.Parse(json))
- {
- result = doc.RootElement.GetString();
- return true;
- }
- case TypeCode.DateTime:
- // If already unwrapped (no quotes), parse directly; otherwise use JSON parser
- if (json.Length == 0 || json[0] != '"')
- {
- result = DateTime.Parse(json, CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.RoundtripKind);
- return true;
- }
- using (var doc = JsonDocument.Parse(json))
- {
- result = doc.RootElement.GetDateTime();
- return true;
- }
- case TypeCode.Byte: result = byte.Parse(json, CultureInfo.InvariantCulture); return true;
- case TypeCode.Int16: result = short.Parse(json, CultureInfo.InvariantCulture); return true;
- case TypeCode.UInt16: result = ushort.Parse(json, CultureInfo.InvariantCulture); return true;
- case TypeCode.UInt32: result = uint.Parse(json, CultureInfo.InvariantCulture); return true;
- case TypeCode.UInt64: result = ulong.Parse(json, CultureInfo.InvariantCulture); return true;
- case TypeCode.SByte: result = sbyte.Parse(json, CultureInfo.InvariantCulture); return true;
- case TypeCode.Char:
- if (json.Length == 0 || json[0] != '"')
- {
- result = json.Length > 0 ? json[0] : '\0';
- return true;
- }
- using (var doc = JsonDocument.Parse(json))
- {
- var s = doc.RootElement.GetString();
- result = s?.Length > 0 ? s[0] : '\0';
- return true;
- }
- }
-
- if (ReferenceEquals(type, GuidType))
- {
- // If already unwrapped (no quotes), parse directly
- if (json.Length == 0 || json[0] != '"')
- {
- result = Guid.Parse(json);
- return true;
- }
- using var doc = JsonDocument.Parse(json);
- result = doc.RootElement.GetGuid();
- return true;
- }
-
- if (ReferenceEquals(type, DateTimeOffsetType))
- {
- // If already unwrapped (no quotes), parse directly
- if (json.Length == 0 || json[0] != '"')
- {
- result = DateTimeOffset.Parse(json, CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.RoundtripKind);
- return true;
- }
- using var doc = JsonDocument.Parse(json);
- result = doc.RootElement.GetDateTimeOffset();
- return true;
- }
-
- if (ReferenceEquals(type, TimeSpanType))
- {
- // If already unwrapped (no quotes), parse directly
- if (json.Length == 0 || json[0] != '"')
- {
- result = TimeSpan.Parse(json, CultureInfo.InvariantCulture);
- return true;
- }
- using var doc = JsonDocument.Parse(json);
- result = TimeSpan.Parse(doc.RootElement.GetString()!, CultureInfo.InvariantCulture);
- return true;
- }
-
- result = null;
- return false;
- }
-
- #endregion
-
- #region Context Pool
-
- private static class DeserializationContextPool
- {
- private static readonly ConcurrentQueue Pool = new();
- private const int MaxPoolSize = 16;
-
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- public static DeserializationContext Get(in AcJsonSerializerOptions options)
- {
- if (Pool.TryDequeue(out var context))
- {
- context.Reset(options);
- return context;
- }
- return new DeserializationContext(options);
- }
-
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- public static void Return(DeserializationContext context)
- {
- if (Pool.Count < MaxPoolSize)
- {
- context.Clear();
- Pool.Enqueue(context);
- }
- }
- }
-
- #endregion
-
- #region Type Metadata
-
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- private static DeserializeTypeMetadata GetTypeMetadata(in Type type)
- => TypeMetadataCache.GetOrAdd(type, static t => new DeserializeTypeMetadata(t));
-
- private sealed class DeserializeTypeMetadata
- {
- public FrozenDictionary PropertySettersFrozen { get; }
- public PropertySetterInfo[] PropertiesArray { get; } // Array for fast UTF8 linear scan (small objects)
- public Func? CompiledConstructor { get; }
-
- public DeserializeTypeMetadata(Type type)
- {
- var ctor = type.GetConstructor(Type.EmptyTypes);
- if (ctor != null)
- {
- var newExpr = Expression.New(type);
- var boxed = Expression.Convert(newExpr, typeof(object));
- CompiledConstructor = Expression.Lambda>(boxed).Compile();
- }
-
- var allProps = type.GetProperties(BindingFlags.Public | BindingFlags.Instance);
- var propsList = new List(allProps.Length);
-
- foreach (var p in allProps)
- {
- if (!p.CanWrite || !p.CanRead || p.GetIndexParameters().Length != 0) continue;
- if (HasJsonIgnoreAttribute(p)) continue;
- propsList.Add(p);
- }
-
- var propertySetters = new Dictionary(propsList.Count, StringComparer.OrdinalIgnoreCase);
- var propsArray = new PropertySetterInfo[propsList.Count];
- var index = 0;
-
- foreach (var prop in propsList)
- {
- var propInfo = new PropertySetterInfo(prop, type);
- propertySetters[prop.Name] = propInfo;
- propsArray[index++] = propInfo;
- }
-
- PropertiesArray = propsArray;
- // Create frozen dictionary for faster lookup in hot paths
- PropertySettersFrozen = propertySetters.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
- }
-
- ///
- /// Try to find property by UTF-8 name using ValueTextEquals (avoids string allocation).
- ///
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- public bool TryGetPropertyUtf8(ref Utf8JsonReader reader, out PropertySetterInfo? propInfo)
- {
- var props = PropertiesArray;
- for (var i = 0; i < props.Length; i++)
- {
- if (reader.ValueTextEquals(props[i].NameUtf8))
- {
- propInfo = props[i];
- return true;
- }
- }
- propInfo = null;
- return false;
- }
- }
-
- private sealed class PropertySetterInfo
- {
- public readonly Type PropertyType;
- public readonly Type UnderlyingType; // Cached: Nullable.GetUnderlyingType(PropertyType) ?? PropertyType
- public readonly TypeCode PropertyTypeCode; // Cached TypeCode for fast primitive reading
- public readonly bool IsNullable;
- public readonly bool IsIIdCollection;
- public readonly Type? ElementType;
- public readonly Type? ElementIdType;
- public readonly Func? ElementIdGetter;
- public readonly byte[] NameUtf8; // Pre-computed UTF-8 bytes of property name for fast matching
-
- // Typed setters to avoid boxing for primitives
- private readonly Action _setter;
- private readonly Func _getter;
-
- // Typed setters for common primitive types (avoid boxing)
- internal readonly Action? _setInt32;
- internal readonly Action? _setInt64;
- internal readonly Action? _setDouble;
- internal readonly Action? _setBool;
- internal readonly Action? _setDecimal;
- internal readonly Action? _setSingle;
- internal readonly Action? _setDateTime;
- internal readonly Action? _setGuid;
-
- public PropertySetterInfo(PropertyInfo prop, Type declaringType)
- {
- PropertyType = prop.PropertyType;
- var underlying = Nullable.GetUnderlyingType(PropertyType);
- IsNullable = underlying != null;
- UnderlyingType = underlying ?? PropertyType;
- PropertyTypeCode = Type.GetTypeCode(UnderlyingType);
- NameUtf8 = Encoding.UTF8.GetBytes(prop.Name);
-
- _setter = CreateCompiledSetter(declaringType, prop);
- _getter = CreateCompiledGetter(declaringType, prop);
-
- // Create typed setters for common primitives to avoid boxing
- if (!IsNullable)
- {
- if (ReferenceEquals(PropertyType, IntType))
- _setInt32 = CreateTypedSetter(declaringType, prop);
- else if (ReferenceEquals(PropertyType, LongType))
- _setInt64 = CreateTypedSetter(declaringType, prop);
- else if (ReferenceEquals(PropertyType, DoubleType))
- _setDouble = CreateTypedSetter(declaringType, prop);
- else if (ReferenceEquals(PropertyType, BoolType))
- _setBool = CreateTypedSetter(declaringType, prop);
- else if (ReferenceEquals(PropertyType, DecimalType))
- _setDecimal = CreateTypedSetter(declaringType, prop);
- else if (ReferenceEquals(PropertyType, FloatType))
- _setSingle = CreateTypedSetter(declaringType, prop);
- else if (ReferenceEquals(PropertyType, DateTimeType))
- _setDateTime = CreateTypedSetter(declaringType, prop);
- else if (ReferenceEquals(PropertyType, GuidType))
- _setGuid = CreateTypedSetter(declaringType, prop);
- }
-
- ElementType = GetCollectionElementType(PropertyType);
- var isCollection = ElementType != null && ElementType != typeof(object) &&
- typeof(IEnumerable).IsAssignableFrom(PropertyType) &&
- !ReferenceEquals(PropertyType, StringType);
-
- if (isCollection && ElementType != null)
- {
- var idInfo = GetIdInfo(ElementType);
- if (idInfo.IsId)
- {
- IsIIdCollection = true;
- ElementIdType = idInfo.IdType;
- var idProp = ElementType.GetProperty("Id");
- if (idProp != null)
- ElementIdGetter = CreateCompiledGetter(ElementType, idProp);
- }
- }
- }
-
- private static Action 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>(assign, objParam, valueParam).Compile();
- }
-
- private static Func 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>(boxed, objParam).Compile();
- }
-
- private static Action CreateTypedSetter(Type declaringType, PropertyInfo prop)
- {
- var objParam = Expression.Parameter(typeof(object), "obj");
- var valueParam = Expression.Parameter(typeof(T), "value");
- var castObj = Expression.Convert(objParam, declaringType);
- var propAccess = Expression.Property(castObj, prop);
- var assign = Expression.Assign(propAccess, valueParam);
- return Expression.Lambda>(assign, objParam, valueParam).Compile();
- }
-
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- public void SetValue(object target, object? value) => _setter(target, value);
-
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- public object? GetValue(object target) => _getter(target);
-
- ///
- /// Read and set value directly from Utf8JsonReader, avoiding boxing for primitives.
- /// Returns true if value was set, false if it needs fallback to SetValue.
- ///
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- public bool TrySetValueDirect(object target, ref Utf8JsonReader reader)
- {
- var tokenType = reader.TokenType;
-
- // Handle null
- if (tokenType == JsonTokenType.Null)
- {
- if (IsNullable || !PropertyType.IsValueType)
- {
- _setter(target, null);
- return true;
- }
- return true; // Skip null for non-nullable value types
- }
-
- // Fast path for booleans - no boxing needed with typed setter
- if (tokenType == JsonTokenType.True)
- {
- if (_setBool != null) { _setBool(target, true); return true; }
- _setter(target, BoxedTrue);
- return true;
- }
- if (tokenType == JsonTokenType.False)
- {
- if (_setBool != null) { _setBool(target, false); return true; }
- _setter(target, BoxedFalse);
- return true;
- }
-
- // Fast path for numbers - use typed setters when available
- if (tokenType == JsonTokenType.Number)
- {
- if (_setInt32 != null) { _setInt32(target, reader.GetInt32()); return true; }
- if (_setInt64 != null) { _setInt64(target, reader.GetInt64()); return true; }
- if (_setDouble != null) { _setDouble(target, reader.GetDouble()); return true; }
- if (_setDecimal != null) { _setDecimal(target, reader.GetDecimal()); return true; }
- if (_setSingle != null) { _setSingle(target, reader.GetSingle()); return true; }
- return false; // Fallback to boxed path
- }
-
- // Fast path for strings - common types
- if (tokenType == JsonTokenType.String)
- {
- if (ReferenceEquals(UnderlyingType, StringType))
- {
- _setter(target, reader.GetString());
- return true;
- }
- if (_setDateTime != null) { _setDateTime(target, reader.GetDateTime()); return true; }
- if (_setGuid != null) { _setGuid(target, reader.GetGuid()); return true; }
- return false; // Fallback to boxed path
- }
-
- return false; // Complex types need standard handling
- }
-
- // Pre-boxed boolean values to avoid repeated boxing
- private static readonly object BoxedTrue = true;
- private static readonly object BoxedFalse = false;
- }
- #endregion
-
- #region Reference Resolution
-
- private sealed class DeferredReference(string refId, Type targetType)
- {
- public string RefId { get; } = refId;
- public Type TargetType { get; } = targetType;
- }
-
- private readonly struct PropertyToResolve(object target, PropertySetterInfo property, string refId)
- {
- public readonly object Target = target;
- public readonly PropertySetterInfo Property = property;
- public readonly string RefId = refId;
- }
-
- private sealed class DeserializationContext
- {
- private Dictionary? _idToObject;
- private List? _propertiesToResolve;
-
- public bool IsMergeMode { get; set; }
- public bool UseReferenceHandling { get; private set; }
- public byte MaxDepth { get; private set; }
-
- public DeserializationContext(in AcJsonSerializerOptions options)
- {
- Reset(options);
- }
-
- public void Reset(in AcJsonSerializerOptions options)
- {
- UseReferenceHandling = options.UseReferenceHandling;
- MaxDepth = options.MaxDepth;
- IsMergeMode = false;
- }
-
- public void Clear()
- {
- _idToObject?.Clear();
- _propertiesToResolve?.Clear();
- }
-
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- public void RegisterObject(string id, object obj)
- {
- if (!UseReferenceHandling) return;
- _idToObject ??= new Dictionary(8, StringComparer.Ordinal);
- _idToObject[id] = obj;
- }
-
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- public bool TryGetReferencedObject(string id, out object? obj)
- {
- if (_idToObject != null) return _idToObject.TryGetValue(id, out obj);
- obj = null;
- return false;
- }
-
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- public void AddPropertyToResolve(object target, PropertySetterInfo property, string refId)
- {
- _propertiesToResolve ??= new List(4);
- _propertiesToResolve.Add(new PropertyToResolve(target, property, refId));
- }
-
- public void ResolveReferences()
- {
- if (_propertiesToResolve == null || _idToObject == null) return;
-
- foreach (ref readonly var ptr in System.Runtime.InteropServices.CollectionsMarshal.AsSpan(_propertiesToResolve))
- {
- if (_idToObject.TryGetValue(ptr.RefId, out var refObj))
- ptr.Property.SetValue(ptr.Target, refObj);
- }
+ public IPopulateChain ThenPopulate(object target) => this;
+ public void Dispose() { }
}
}
#endregion
}
+
+#region Chain Public Interfaces
+
+///
+/// Represents a deserialize chain that allows multiple deserializations from the same parsed JSON.
+/// Implements IDisposable - call Dispose() when done or use 'using' statement.
+///
+public interface IDeserializeChain : IDisposable
+{
+ ///
+ /// The first deserialized value.
+ ///
+ T? Value { get; }
+
+ ///
+ /// Deserialize to another type from the same JSON.
+ ///
+ TOther? ThenDeserialize();
+}
+
+///
+/// Represents a populate chain that allows populating multiple objects from the same parsed JSON.
+/// Implements IDisposable - call Dispose() when done or use 'using' statement.
+///
+public interface IPopulateChain : IDisposable
+{
+ ///
+ /// Populate another object from the same JSON.
+ ///
+ IPopulateChain ThenPopulate(object target);
+}
+
+#endregion
diff --git a/AyCode.Core/Serializers/Jsons/AcJsonSerializer.SerializationContext.cs b/AyCode.Core/Serializers/Jsons/AcJsonSerializer.SerializationContext.cs
new file mode 100644
index 0000000..8219d43
--- /dev/null
+++ b/AyCode.Core/Serializers/Jsons/AcJsonSerializer.SerializationContext.cs
@@ -0,0 +1,142 @@
+using System.Buffers;
+using System.Collections.Concurrent;
+using System.Runtime.CompilerServices;
+using System.Text;
+using System.Text.Json;
+
+namespace AyCode.Core.Serializers.Jsons;
+
+public static partial class AcJsonSerializer
+{
+ private static class SerializationContextPool
+ {
+ private static readonly ConcurrentQueue Pool = new();
+ private const int MaxPoolSize = 16;
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static SerializationContext Get(in AcJsonSerializerOptions options)
+ {
+ if (Pool.TryDequeue(out var context))
+ {
+ context.Reset(options);
+ return context;
+ }
+ return new SerializationContext(options);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static void Return(SerializationContext context)
+ {
+ if (Pool.Count < MaxPoolSize)
+ {
+ context.Clear();
+ Pool.Enqueue(context);
+ }
+ }
+ }
+
+ private sealed class SerializationContext : IDisposable
+ {
+ private readonly ArrayBufferWriter _buffer;
+ public Utf8JsonWriter Writer { get; private set; }
+
+ private Dictionary? _scanOccurrences;
+ private Dictionary? _writtenRefs;
+ private HashSet? _multiReferenced;
+ private int _nextId;
+
+ public bool UseReferenceHandling { get; private set; }
+ public byte MaxDepth { get; private set; }
+
+ private static readonly JsonWriterOptions WriterOptions = new()
+ {
+ Indented = false,
+ SkipValidation = true // Skip validation for performance
+ };
+
+ public SerializationContext(in AcJsonSerializerOptions options)
+ {
+ _buffer = new ArrayBufferWriter(4096);
+ Writer = new Utf8JsonWriter(_buffer, WriterOptions);
+ Reset(options);
+ }
+
+ public void Reset(in AcJsonSerializerOptions options)
+ {
+ UseReferenceHandling = options.UseReferenceHandling;
+ MaxDepth = options.MaxDepth;
+ _nextId = 1;
+
+ if (UseReferenceHandling)
+ {
+ _scanOccurrences ??= new Dictionary(64, ReferenceEqualityComparer.Instance);
+ _writtenRefs ??= new Dictionary(32, ReferenceEqualityComparer.Instance);
+ _multiReferenced ??= new HashSet(32, ReferenceEqualityComparer.Instance);
+ }
+ }
+
+ public void Clear()
+ {
+ Writer.Reset();
+ _buffer.Clear();
+ _scanOccurrences?.Clear();
+ _writtenRefs?.Clear();
+ _multiReferenced?.Clear();
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public bool TrackForScanning(object obj)
+ {
+ if (_scanOccurrences == null) return true;
+
+ ref var count = ref System.Runtime.InteropServices.CollectionsMarshal.GetValueRefOrAddDefault(_scanOccurrences, obj, out var exists);
+ if (exists) { count++; _multiReferenced!.Add(obj); return false; }
+ count = 1;
+ return true;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public bool ShouldWriteId(object obj, out string id)
+ {
+ if (_multiReferenced != null && _multiReferenced.Contains(obj) && !_writtenRefs!.ContainsKey(obj))
+ {
+ id = _nextId++.ToString();
+ return true;
+ }
+ id = "";
+ return false;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public void MarkAsWritten(object obj, string id) => _writtenRefs![obj] = id;
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public bool TryGetExistingRef(object obj, out string refId)
+ {
+ if (_writtenRefs != null) return _writtenRefs.TryGetValue(obj, out refId!);
+ refId = "";
+ return false;
+ }
+
+ public string GetResult()
+ {
+ Writer.Flush();
+ return Encoding.UTF8.GetString(_buffer.WrittenSpan);
+ }
+
+ public void Dispose()
+ {
+ Writer.Dispose();
+ }
+ }
+}
+
+///
+/// Reference equality comparer for object identity comparison.
+///
+internal sealed class ReferenceEqualityComparer : IEqualityComparer
+{
+ public static readonly ReferenceEqualityComparer Instance = new();
+ public new bool Equals(object? x, object? y) => ReferenceEquals(x, y);
+ public int GetHashCode(object obj) => RuntimeHelpers.GetHashCode(obj);
+}
diff --git a/AyCode.Core/Serializers/Jsons/AcJsonSerializer.TypeMetadata.cs b/AyCode.Core/Serializers/Jsons/AcJsonSerializer.TypeMetadata.cs
new file mode 100644
index 0000000..b3a455b
--- /dev/null
+++ b/AyCode.Core/Serializers/Jsons/AcJsonSerializer.TypeMetadata.cs
@@ -0,0 +1,53 @@
+using System.Collections.Concurrent;
+using System.Linq.Expressions;
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using static AyCode.Core.Helpers.JsonUtilities;
+
+namespace AyCode.Core.Serializers.Jsons;
+
+public static partial class AcJsonSerializer
+{
+ private static readonly ConcurrentDictionary TypeMetadataCache = new();
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static TypeMetadata GetTypeMetadata(in Type type)
+ => TypeMetadataCache.GetOrAdd(type, static t => new TypeMetadata(t));
+
+ private sealed class TypeMetadata
+ {
+ public PropertyAccessor[] Properties { get; }
+
+ public TypeMetadata(Type type)
+ {
+ Properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
+ .Where(p => p.CanRead &&
+ p.GetIndexParameters().Length == 0 &&
+ !HasJsonIgnoreAttribute(p))
+ .Select(p => new PropertyAccessor(p))
+ .ToArray();
+ }
+ }
+
+ private sealed class PropertyAccessor
+ {
+ public readonly string JsonName;
+ public readonly JsonEncodedText JsonNameEncoded; // STJ optimization - pre-encoded property name
+ public readonly Type PropertyType;
+ public readonly TypeCode PropertyTypeCode;
+ private readonly Func _getter;
+
+ public PropertyAccessor(PropertyInfo prop)
+ {
+ JsonName = prop.Name;
+ JsonNameEncoded = JsonEncodedText.Encode(prop.Name);
+ PropertyType = Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType;
+ PropertyTypeCode = Type.GetTypeCode(PropertyType);
+ _getter = AcSerializerCommon.CreateCompiledGetter(prop.DeclaringType!, prop);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public object? GetValue(object obj) => _getter(obj);
+ }
+}
diff --git a/AyCode.Core/Serializers/Jsons/AcJsonSerializer.cs b/AyCode.Core/Serializers/Jsons/AcJsonSerializer.cs
index e3b3efa..6ea0ccc 100644
--- a/AyCode.Core/Serializers/Jsons/AcJsonSerializer.cs
+++ b/AyCode.Core/Serializers/Jsons/AcJsonSerializer.cs
@@ -1,12 +1,10 @@
-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;
using System.Text.Json;
+using AyCode.Core.Serializers.Expressions;
using static AyCode.Core.Helpers.JsonUtilities;
namespace AyCode.Core.Serializers.Jsons;
@@ -14,14 +12,14 @@ namespace AyCode.Core.Serializers.Jsons;
///
/// High-performance custom JSON serializer optimized for IId<T> reference handling.
/// Uses Utf8JsonWriter for high-performance UTF-8 output (STJ approach).
+/// Supports Expression tree serialization.
///
-public static class AcJsonSerializer
+public static partial class AcJsonSerializer
{
- private static readonly ConcurrentDictionary TypeMetadataCache = new();
-
// Pre-encoded property names for $id/$ref (STJ optimization)
private static readonly JsonEncodedText IdPropertyEncoded = JsonEncodedText.Encode("$id");
private static readonly JsonEncodedText RefPropertyEncoded = JsonEncodedText.Encode("$ref");
+ private static readonly Type ExpressionBaseType = typeof(Expression);
///
/// Serialize object to JSON string with default options.
@@ -38,16 +36,30 @@ public static class AcJsonSerializer
var type = value.GetType();
- if (TrySerializePrimitiveRuntime(value, type, out var primitiveJson))
+ // Handle IQueryable types - convert to AcExpressionNode (serialize the Expression)
+ object actualValue = value;
+ if (value is IQueryable queryable)
+ {
+ actualValue = AcSerializerCommon.QueryableToNode(queryable);
+ type = typeof(AcExpressionNode);
+ }
+ // Handle Expression types - convert to AcExpressionNode
+ else if (IsExpressionType(type))
+ {
+ actualValue = AcExpressionConverter.ToNode((Expression)(object)value);
+ type = typeof(AcExpressionNode);
+ }
+
+ if (TrySerializePrimitiveRuntime(actualValue, type, out var primitiveJson))
return primitiveJson;
var context = SerializationContextPool.Get(options);
try
{
if (options.UseReferenceHandling)
- ScanReferences(value, context, 0);
+ ScanReferences(actualValue, context, 0);
- WriteValue(value, context, 0);
+ WriteValue(actualValue, context, 0);
return context.GetResult();
}
finally
@@ -56,6 +68,15 @@ public static class AcJsonSerializer
}
}
+ ///
+ /// Checks if a type is an Expression type.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static bool IsExpressionType(Type type)
+ {
+ return ExpressionBaseType.IsAssignableFrom(type);
+ }
+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool TrySerializePrimitiveRuntime(object value, in Type type, out string json)
{
@@ -294,197 +315,4 @@ public static class AcJsonSerializer
}
#endregion
-
- #region Type Metadata
-
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- private static TypeMetadata GetTypeMetadata(in Type type)
- => TypeMetadataCache.GetOrAdd(type, static t => new TypeMetadata(t));
-
- private sealed class TypeMetadata
- {
- public PropertyAccessor[] Properties { get; }
-
- public TypeMetadata(Type type)
- {
- Properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
- .Where(p => p.CanRead &&
- p.GetIndexParameters().Length == 0 &&
- !HasJsonIgnoreAttribute(p))
- .Select(p => new PropertyAccessor(p))
- .ToArray();
- }
- }
-
- private sealed class PropertyAccessor
- {
- public readonly string JsonName;
- public readonly JsonEncodedText JsonNameEncoded; // STJ optimization - pre-encoded property name
- public readonly Type PropertyType;
- public readonly TypeCode PropertyTypeCode;
- private readonly Func _getter;
-
- public PropertyAccessor(PropertyInfo prop)
- {
- JsonName = prop.Name;
- JsonNameEncoded = JsonEncodedText.Encode(prop.Name);
- PropertyType = Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType;
- PropertyTypeCode = Type.GetTypeCode(PropertyType);
- _getter = CreateCompiledGetter(prop.DeclaringType!, prop);
- }
-
- private static Func 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>(boxed, objParam).Compile();
- }
-
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- public object? GetValue(object obj) => _getter(obj);
- }
-
- #endregion
-
- #region Context Pool
-
- private static class SerializationContextPool
- {
- private static readonly ConcurrentQueue Pool = new();
- private const int MaxPoolSize = 16;
-
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- public static SerializationContext Get(in AcJsonSerializerOptions options)
- {
- if (Pool.TryDequeue(out var context))
- {
- context.Reset(options);
- return context;
- }
- return new SerializationContext(options);
- }
-
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- public static void Return(SerializationContext context)
- {
- if (Pool.Count < MaxPoolSize)
- {
- context.Clear();
- Pool.Enqueue(context);
- }
- }
- }
-
- #endregion
-
- #region Serialization Context
-
- private sealed class SerializationContext : IDisposable
- {
- private readonly ArrayBufferWriter _buffer;
- public Utf8JsonWriter Writer { get; private set; }
-
- private Dictionary? _scanOccurrences;
- private Dictionary? _writtenRefs;
- private HashSet? _multiReferenced;
- private int _nextId;
-
- public bool UseReferenceHandling { get; private set; }
- public byte MaxDepth { get; private set; }
-
- private static readonly JsonWriterOptions WriterOptions = new()
- {
- Indented = false,
- SkipValidation = true // Skip validation for performance
- };
-
- public SerializationContext(in AcJsonSerializerOptions options)
- {
- _buffer = new ArrayBufferWriter(4096);
- Writer = new Utf8JsonWriter(_buffer, WriterOptions);
- Reset(options);
- }
-
- public void Reset(in AcJsonSerializerOptions options)
- {
- UseReferenceHandling = options.UseReferenceHandling;
- MaxDepth = options.MaxDepth;
- _nextId = 1;
-
- if (UseReferenceHandling)
- {
- _scanOccurrences ??= new Dictionary(64, ReferenceEqualityComparer.Instance);
- _writtenRefs ??= new Dictionary(32, ReferenceEqualityComparer.Instance);
- _multiReferenced ??= new HashSet(32, ReferenceEqualityComparer.Instance);
- }
- }
-
- public void Clear()
- {
- Writer.Reset();
- _buffer.Clear();
- _scanOccurrences?.Clear();
- _writtenRefs?.Clear();
- _multiReferenced?.Clear();
- }
-
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- public bool TrackForScanning(object obj)
- {
- if (_scanOccurrences == null) return true;
-
- ref var count = ref System.Runtime.InteropServices.CollectionsMarshal.GetValueRefOrAddDefault(_scanOccurrences, obj, out var exists);
- if (exists) { count++; _multiReferenced!.Add(obj); return false; }
- count = 1;
- return true;
- }
-
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- public bool ShouldWriteId(object obj, out string id)
- {
- if (_multiReferenced != null && _multiReferenced.Contains(obj) && !_writtenRefs!.ContainsKey(obj))
- {
- id = _nextId++.ToString();
- return true;
- }
- id = "";
- return false;
- }
-
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- public void MarkAsWritten(object obj, string id) => _writtenRefs![obj] = id;
-
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- public bool TryGetExistingRef(object obj, out string refId)
- {
- if (_writtenRefs != null) return _writtenRefs.TryGetValue(obj, out refId!);
- refId = "";
- return false;
- }
-
- public string GetResult()
- {
- Writer.Flush();
- return Encoding.UTF8.GetString(_buffer.WrittenSpan);
- }
-
- public void Dispose()
- {
- Writer.Dispose();
- }
- }
-
- #endregion
-}
-
-///
-/// Reference equality comparer for object identity comparison.
-///
-internal sealed class ReferenceEqualityComparer : IEqualityComparer
-{
- public static readonly ReferenceEqualityComparer Instance = new();
- public new bool Equals(object? x, object? y) => ReferenceEquals(x, y);
- public int GetHashCode(object obj) => RuntimeHelpers.GetHashCode(obj);
}
diff --git a/AyCode.Services.Server.Tests/SignalRs/SignalRDatasources/SignalRDataSourceTestBase.Collections.cs b/AyCode.Services.Server.Tests/SignalRs/SignalRDatasources/SignalRDataSourceTestBase.Collections.cs
new file mode 100644
index 0000000..9e07ee3
--- /dev/null
+++ b/AyCode.Services.Server.Tests/SignalRs/SignalRDatasources/SignalRDataSourceTestBase.Collections.cs
@@ -0,0 +1,383 @@
+using System.Collections;
+using AyCode.Core.Enums;
+using AyCode.Core.Tests.TestModels;
+
+namespace AyCode.Services.Server.Tests.SignalRs.SignalRDatasources;
+
+public abstract partial class SignalRDataSourceTestBase
+{
+ #region Collection Operations Tests
+
+ [TestMethod]
+ public virtual async Task Count_ReturnsCorrectValue()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+ await dataSource.LoadDataSource();
+
+ Assert.AreEqual(3, dataSource.Count);
+ }
+
+ [TestMethod]
+ public virtual void Clear_RemovesAllItems()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+
+ dataSource.Add(new TestOrderItem { Id = 1, ProductName = "Item1" });
+ dataSource.Add(new TestOrderItem { Id = 2, ProductName = "Item2" });
+ dataSource.Clear();
+
+ Assert.AreEqual(0, dataSource.Count);
+ Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
+ }
+
+ [TestMethod]
+ public virtual void Clear_WithoutClearingTracking_PreservesTracking()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+
+ dataSource.Add(new TestOrderItem { Id = 1, ProductName = "Item1" });
+ dataSource.Clear(clearChangeTracking: false);
+
+ Assert.AreEqual(0, dataSource.Count);
+ Assert.AreEqual(1, dataSource.GetTrackingItems().Count);
+ }
+
+ [TestMethod]
+ public virtual async Task Contains_ReturnsTrue_WhenItemExists()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+ await dataSource.LoadDataSource();
+
+ Assert.IsTrue(dataSource.Contains(dataSource[0]));
+ }
+
+ [TestMethod]
+ public virtual void Contains_ReturnsFalse_WhenItemNotExists()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+ Assert.IsFalse(dataSource.Contains(new TestOrderItem { Id = 9999 }));
+ }
+
+ [TestMethod]
+ public virtual async Task IndexOf_ReturnsCorrectIndex()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+ await dataSource.LoadDataSource();
+
+ Assert.AreEqual(0, dataSource.IndexOf(dataSource[0]));
+ Assert.AreEqual(1, dataSource.IndexOf(dataSource[1]));
+ Assert.AreEqual(2, dataSource.IndexOf(dataSource[2]));
+ }
+
+ [TestMethod]
+ public virtual void IndexOf_ById_ReturnsCorrectIndex()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+
+ dataSource.Add(new TestOrderItem { Id = 5, ProductName = "Item5" });
+ Assert.AreEqual(0, dataSource.IndexOf(5));
+ }
+
+ [TestMethod]
+ public virtual void TryGetIndex_ReturnsTrue_WhenExists()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+
+ dataSource.Add(new TestOrderItem { Id = 10, ProductName = "Test" });
+ var result = dataSource.TryGetIndex(10, out var index);
+
+ Assert.IsTrue(result);
+ Assert.AreEqual(0, index);
+ }
+
+ [TestMethod]
+ public virtual async Task TryGetValue_ReturnsItem_WhenExists()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+
+ await dataSource.LoadDataSource();
+ var result = dataSource.TryGetValue(1, out var item);
+
+ Assert.IsTrue(result);
+ Assert.IsNotNull(item);
+ Assert.AreEqual("Product A", item.ProductName);
+ }
+
+ [TestMethod]
+ public virtual void TryGetValue_ReturnsFalse_WhenNotExists()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+ var result = dataSource.TryGetValue(9999, out var item);
+
+ Assert.IsFalse(result);
+ Assert.IsNull(item);
+ }
+
+ [TestMethod]
+ public virtual async Task CopyTo_CopiesAllItems()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+
+ await dataSource.LoadDataSource();
+ var array = new TestOrderItem[3];
+ dataSource.CopyTo(array);
+
+ Assert.AreEqual("Product A", array[0].ProductName);
+ Assert.AreEqual("Product B", array[1].ProductName);
+ Assert.AreEqual("Product C", array[2].ProductName);
+ }
+
+ [TestMethod]
+ public virtual async Task GetEnumerator_EnumeratesAllItems()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+ await dataSource.LoadDataSource();
+
+ var count = dataSource.Count();
+
+ Assert.AreEqual(3, count);
+ }
+
+ [TestMethod]
+ public virtual async Task AsReadOnly_ReturnsReadOnlyCollection()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+
+ await dataSource.LoadDataSource();
+ var readOnly = dataSource.AsReadOnly();
+
+ Assert.AreEqual(3, readOnly.Count);
+ Assert.IsInstanceOfType(readOnly, typeof(System.Collections.ObjectModel.ReadOnlyCollection));
+ }
+
+ #endregion
+
+ #region Working Reference List Tests
+
+ [TestMethod]
+ public virtual async Task SetWorkingReferenceList_SetsNewInnerList()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+ await dataSource.LoadDataSource();
+
+ var externalList = Activator.CreateInstance();
+ dataSource.SetWorkingReferenceList(externalList);
+
+ Assert.IsTrue(dataSource.HasWorkingReferenceList);
+ Assert.AreEqual(3, dataSource.Count);
+ }
+
+ [TestMethod]
+ public virtual async Task GetReferenceInnerList_ReturnsInnerList()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+
+ await dataSource.LoadDataSource();
+ var innerList = dataSource.GetReferenceInnerList();
+
+ Assert.IsNotNull(innerList);
+ Assert.AreEqual(3, innerList.Count);
+ }
+
+ #endregion
+
+ #region Sync State Tests
+
+ [TestMethod]
+ public virtual void IsSyncing_IsFalse_Initially()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+ Assert.IsFalse(dataSource.IsSyncing);
+ }
+
+ [TestMethod]
+ public virtual async Task OnSyncingStateChanged_Fires_DuringLoad()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+ var syncStarted = false;
+ var syncEnded = false;
+
+ dataSource.OnSyncingStateChanged += isSyncing =>
+ {
+ if (isSyncing) syncStarted = true;
+ else syncEnded = true;
+ };
+
+ await dataSource.LoadDataSource();
+
+ Assert.IsTrue(syncStarted);
+ Assert.IsTrue(syncEnded);
+ }
+
+ #endregion
+
+ #region IList Interface Tests
+
+ [TestMethod]
+ public virtual void IList_Add_ReturnsCorrectIndex()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+ var item = new TestOrderItem { Id = 1, ProductName = "Test" };
+ var index = ((IList)dataSource).Add(item);
+
+ Assert.AreEqual(0, index);
+ }
+
+ [TestMethod]
+ public virtual void IList_Contains_WorksCorrectly()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+
+ var item = new TestOrderItem { Id = 1, ProductName = "Test" };
+ dataSource.Add(item);
+
+ Assert.IsTrue(((IList)dataSource).Contains(item));
+ }
+
+ [TestMethod]
+ public virtual void IList_IndexOf_WorksCorrectly()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+
+ var item = new TestOrderItem { Id = 1, ProductName = "Test" };
+ dataSource.Add(item);
+
+ Assert.AreEqual(0, ((IList)dataSource).IndexOf(item));
+ }
+
+ [TestMethod]
+ public virtual void IList_Insert_WorksCorrectly()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+
+ dataSource.Add(new TestOrderItem { Id = 1, ProductName = "First" });
+ var newItem = new TestOrderItem { Id = 2, ProductName = "Inserted" };
+
+ ((IList)dataSource).Insert(0, newItem);
+
+ Assert.AreEqual("Inserted", dataSource[0].ProductName);
+ }
+
+ [TestMethod]
+ public virtual void IList_Remove_WorksCorrectly()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+
+ var item = new TestOrderItem { Id = 1, ProductName = "Test" };
+ dataSource.Add(item);
+
+ ((IList)dataSource).Remove(item);
+ Assert.AreEqual(0, dataSource.Count);
+ }
+
+ [TestMethod]
+ public virtual void IList_Indexer_GetSet_WorksCorrectly()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+
+ dataSource.Add(new TestOrderItem { Id = 1, ProductName = "Original" });
+ ((IList)dataSource)[0] = new TestOrderItem { Id = 1, ProductName = "Modified" };
+
+ Assert.AreEqual("Modified", dataSource[0].ProductName);
+ }
+
+ [TestMethod]
+ public virtual void ICollection_CopyTo_WorksCorrectly()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+
+ dataSource.Add(new TestOrderItem { Id = 1, ProductName = "Item1" });
+ dataSource.Add(new TestOrderItem { Id = 2, ProductName = "Item2" });
+
+ var array = new TestOrderItem[2];
+ ((ICollection)dataSource).CopyTo(array, 0);
+
+ Assert.AreEqual("Item1", array[0].ProductName);
+ Assert.AreEqual("Item2", array[1].ProductName);
+ }
+
+ [TestMethod]
+ public virtual void IsSynchronized_ReturnsTrue()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+ Assert.IsTrue(dataSource.IsSynchronized);
+ }
+
+ [TestMethod]
+ public virtual void SyncRoot_IsNotNull()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+ Assert.IsNotNull(dataSource.SyncRoot);
+ }
+
+ [TestMethod]
+ public virtual void IsFixedSize_ReturnsFalse()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+ Assert.IsFalse(dataSource.IsFixedSize);
+ }
+
+ [TestMethod]
+ public virtual void IsReadOnly_ReturnsFalse()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+ Assert.IsFalse(((IList)dataSource).IsReadOnly);
+ }
+
+ #endregion
+
+ #region Edge Cases
+
+ [TestMethod]
+ public virtual async Task Indexer_OutOfRange_ThrowsException()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+ await dataSource.LoadDataSource();
+
+ Assert.ThrowsExactly(() => _ = dataSource[999]);
+ }
+
+ [TestMethod]
+ public virtual void Add_ThenRemove_ClearsTracking()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+ var item = new TestOrderItem { Id = 1, ProductName = "Temporary" };
+
+ dataSource.Add(item);
+ Assert.AreEqual(1, dataSource.GetTrackingItems().Count);
+
+ dataSource.Remove(item);
+ Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
+ }
+
+ [TestMethod]
+ public virtual async Task ComplexWorkflow_AddUpdateRemoveSave()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+
+ // Add items
+ dataSource.Add(new TestOrderItem { Id = 1, ProductName = "Item1", Quantity = 10 });
+ dataSource.Add(new TestOrderItem { Id = 2, ProductName = "Item2", Quantity = 20 });
+ Assert.AreEqual(2, dataSource.GetTrackingItems().Count);
+
+ // Save
+ await dataSource.SaveChanges();
+ Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
+
+ // Update
+ dataSource[0] = new TestOrderItem { Id = 1, ProductName = "Updated1", Quantity = 100 };
+ Assert.AreEqual(1, dataSource.GetTrackingItems().Count);
+ Assert.AreEqual(TrackingState.Update, dataSource.GetTrackingItems()[0].TrackingState);
+
+ // Remove
+ dataSource.Remove(dataSource[1]);
+ Assert.AreEqual(2, dataSource.GetTrackingItems().Count);
+
+ // Save all changes
+ await dataSource.SaveChanges();
+ Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
+ Assert.AreEqual(1, dataSource.Count);
+ }
+
+ #endregion
+}
diff --git a/AyCode.Services.Server.Tests/SignalRs/SignalRDatasources/SignalRDataSourceTestBase.ContextAndFilter.cs b/AyCode.Services.Server.Tests/SignalRs/SignalRDatasources/SignalRDataSourceTestBase.ContextAndFilter.cs
new file mode 100644
index 0000000..ce77678
--- /dev/null
+++ b/AyCode.Services.Server.Tests/SignalRs/SignalRDatasources/SignalRDataSourceTestBase.ContextAndFilter.cs
@@ -0,0 +1,470 @@
+using System.Linq.Expressions;
+using AyCode.Core.Serializers;
+using AyCode.Core.Serializers.Expressions;
+using AyCode.Core.Tests.TestModels;
+using AyCode.Services.SignalRs;
+
+namespace AyCode.Services.Server.Tests.SignalRs.SignalRDatasources;
+
+public abstract partial class SignalRDataSourceTestBase
+{
+ #region Context and Filter Tests
+
+ [TestMethod]
+ public virtual void ContextIds_CanBeSetAndRetrieved()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+
+ var contextIds = new object[] { 1, "test", Guid.NewGuid() };
+ dataSource.ContextIds = contextIds;
+
+ Assert.AreEqual(3, dataSource.ContextIds?.Length);
+ }
+
+ [TestMethod]
+ public virtual void FilterText_CanBeSetAndRetrieved()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+ dataSource.FilterText = "search term";
+
+ Assert.AreEqual("search term", dataSource.FilterText);
+ }
+
+ #endregion
+
+ #region Expression / AcLinq Filter Tests
+
+ ///
+ /// Creates CRUD tags for Expression-enabled DataSource.
+ ///
+ private SignalRCrudTags CreateExpressionCrudTags() => new(
+ TestSignalRTags.ExpressionDataSourceGetAll,
+ TestSignalRTags.DataSourceGetItem,
+ TestSignalRTags.DataSourceAdd,
+ TestSignalRTags.DataSourceUpdate,
+ TestSignalRTags.DataSourceRemove
+ );
+
+ [TestMethod]
+ public virtual async Task ExpressionFilter_WithQuantityGreaterThan_ReturnsFilteredItems()
+ {
+ // Arrange - Create DataSource with Expression-enabled GetAll tag
+ var expressionCrudTags = CreateExpressionCrudTags();
+ var dataSource = CreateDataSource(_client, expressionCrudTags);
+
+ // Create filter as Expression - serializer automatically converts to AcExpressionNode
+ Expression> filter = item => item.Quantity > 15;
+
+ // Set filter as ContextIds - serializer handles the conversion
+ dataSource.ContextIds = [filter];
+
+ // Act
+ await dataSource.LoadDataSource();
+
+ // Assert - _dataSourceItems has: Quantity 10, 20, 30. Only 20 and 30 match > 15
+ Assert.AreEqual(2, dataSource.Count);
+ Assert.IsTrue(dataSource.All(x => x.Quantity > 15));
+ }
+
+ [TestMethod]
+ public virtual async Task ExpressionFilter_WithUnitPriceGreaterThan_ReturnsFilteredItems()
+ {
+ // Arrange
+ var expressionCrudTags = CreateExpressionCrudTags();
+ var dataSource = CreateDataSource(_client, expressionCrudTags);
+
+ // Create filter as Expression - no manual conversion needed
+ var minPrice = 150m;
+ Expression> filter = item => item.UnitPrice > minPrice;
+
+ dataSource.ContextIds = [filter];
+
+ // Act
+ await dataSource.LoadDataSource();
+
+ // Assert - _dataSourceItems has: UnitPrice 100, 200, 300. Only 200 and 300 match > 150
+ Assert.AreEqual(2, dataSource.Count);
+ Assert.IsTrue(dataSource.All(x => x.UnitPrice > 150m));
+ }
+
+ [TestMethod]
+ public virtual async Task ExpressionFilter_WithNoFilter_ReturnsAllItems()
+ {
+ // Arrange - Use the standard CRUD tags (no Expression filter)
+ // This tests that regular GetAll still works
+ var dataSource = CreateDataSource(_client, _crudTags);
+
+ // No ContextIds set - uses standard DataSourceGetAll
+
+ // Act
+ await dataSource.LoadDataSource();
+
+ // Assert - Should return all 3 items
+ Assert.AreEqual(3, dataSource.Count);
+ }
+
+ [TestMethod]
+ public virtual async Task ExpressionFilter_WithProductNameContains_ReturnsFilteredItems()
+ {
+ // Arrange
+ var expressionCrudTags = CreateExpressionCrudTags();
+ var dataSource = CreateDataSource(_client, expressionCrudTags);
+
+ // Create filter as Expression
+ Expression> filter = item => item.ProductName!.Contains("A");
+
+ dataSource.ContextIds = [filter];
+
+ // Act
+ await dataSource.LoadDataSource();
+
+ // Assert - Only "Product A" contains "A"
+ Assert.AreEqual(1, dataSource.Count);
+ Assert.AreEqual("Product A", dataSource[0].ProductName);
+ }
+
+ [TestMethod]
+ public virtual async Task ExpressionFilter_WithComplexCondition_ReturnsFilteredItems()
+ {
+ // Arrange
+ var expressionCrudTags = CreateExpressionCrudTags();
+ var dataSource = CreateDataSource(_client, expressionCrudTags);
+
+ // Create filter as Expression - complex condition
+ Expression> filter = item => item.Quantity >= 20 && item.UnitPrice < 250m;
+
+ dataSource.ContextIds = [filter];
+
+ // Act
+ await dataSource.LoadDataSource();
+
+ // Assert - Only "Product B" (Quantity=20, UnitPrice=200) matches
+ Assert.AreEqual(1, dataSource.Count);
+ Assert.AreEqual("Product B", dataSource[0].ProductName);
+ }
+
+ #endregion
+
+ #region IQueryable Filter Tests
+
+ ///
+ /// Creates CRUD tags for IQueryable-enabled DataSource.
+ ///
+ private SignalRCrudTags CreateQueryableCrudTags() => new(
+ TestSignalRTags.QueryableDataSourceGetAll,
+ TestSignalRTags.DataSourceGetItem,
+ TestSignalRTags.DataSourceAdd,
+ TestSignalRTags.DataSourceUpdate,
+ TestSignalRTags.DataSourceRemove
+ );
+
+ [TestMethod]
+ public virtual async Task QueryableFilter_WithLambdaFilter_ReturnsFilteredItems()
+ {
+ // Arrange - Create DataSource with IQueryable-enabled GetAll tag
+ var queryableCrudTags = CreateQueryableCrudTags();
+ var dataSource = CreateDataSource(_client, queryableCrudTags);
+
+ // Create a lambda filter Expression - this gets wrapped in Where on the server
+ Expression> filter = x => x.Quantity > 15;
+
+ // Convert to AcExpressionNode for sending
+ var filterNode = AcExpressionConverter.ToNode(filter);
+ dataSource.ContextIds = [filterNode];
+
+ // Act
+ await dataSource.LoadDataSource();
+
+ // Assert - _dataSourceItems has: Quantity 10, 20, 30. Only 20 and 30 match > 15
+ Assert.AreEqual(2, dataSource.Count);
+ Assert.IsTrue(dataSource.All(x => x.Quantity > 15));
+ }
+
+ [TestMethod]
+ public virtual async Task QueryableFilter_WithDecimalComparison_ReturnsFilteredItems()
+ {
+ // Arrange
+ var queryableCrudTags = CreateQueryableCrudTags();
+ var dataSource = CreateDataSource(_client, queryableCrudTags);
+
+ // Create a lambda filter with decimal comparison
+ var minPrice = 150m;
+ Expression> filter = x => x.UnitPrice > minPrice;
+
+ var filterNode = AcExpressionConverter.ToNode(filter);
+ dataSource.ContextIds = [filterNode];
+
+ // Act
+ await dataSource.LoadDataSource();
+
+ // Assert - UnitPrice 100, 200, 300. Only 200 and 300 match > 150
+ Assert.AreEqual(2, dataSource.Count);
+ Assert.IsTrue(dataSource.All(x => x.UnitPrice > 150m));
+ }
+
+ [TestMethod]
+ public virtual async Task QueryableFilter_WithComplexCondition_ReturnsFilteredItems()
+ {
+ // Arrange
+ var queryableCrudTags = CreateQueryableCrudTags();
+ var dataSource = CreateDataSource(_client, queryableCrudTags);
+
+ // Create a filter with AND condition
+ Expression> filter = x => x.Quantity >= 20 && x.UnitPrice < 250m;
+
+ var filterNode = AcExpressionConverter.ToNode(filter);
+ dataSource.ContextIds = [filterNode];
+
+ // Act
+ await dataSource.LoadDataSource();
+
+ // Assert - Only "Product B" (Quantity=20, UnitPrice=200) matches
+ Assert.AreEqual(1, dataSource.Count);
+ Assert.AreEqual("Product B", dataSource[0].ProductName);
+ }
+
+ [TestMethod]
+ public virtual async Task QueryableFilter_WithWhereAndCount_ReturnsCount()
+ {
+ // Arrange - Create DataSource with IQueryable-enabled GetAll tag
+ var queryableCrudTags = CreateQueryableCrudTags();
+ var dataSource = CreateDataSource(_client, queryableCrudTags);
+
+ // Create a query chain: Where(x => x.Id > 1).Count()
+ // This tests the method call chain serialization
+ var query = new List().AsQueryable()
+ .Where(x => x.Id > 1);
+
+ var queryNode = AcExpressionConverter.ToNode(query.Expression);
+ dataSource.ContextIds = [queryNode];
+
+ // Act
+ await dataSource.LoadDataSource();
+
+ // Assert - Items with Id > 1 are: Id=2 (Product B), Id=3 (Product C)
+ Assert.AreEqual(2, dataSource.Count);
+ Assert.IsTrue(dataSource.All(x => x.Id > 1));
+ }
+
+ [TestMethod]
+ public virtual async Task QueryableFilter_WithNoFilter_ReturnsAllItems()
+ {
+ // Arrange
+ var queryableCrudTags = CreateQueryableCrudTags();
+ var dataSource = CreateDataSource(_client, queryableCrudTags);
+
+ // No ContextIds - no filter applied
+
+ // Act
+ await dataSource.LoadDataSource();
+
+ // Assert - Should return all 3 items
+ Assert.AreEqual(3, dataSource.Count);
+ }
+
+ #endregion
+
+ #region IQueryable Aggregate Tests (Count, Sum, etc. executed on server)
+
+ [TestMethod]
+ public void QueryableAggregate_CountWithWhere_ExecutesOnServer()
+ {
+ // Arrange - Server data
+ var serverItems = new List
+ {
+ new() { Id = 1, ProductName = "Product A", Quantity = 10 },
+ new() { Id = 2, ProductName = "Product B", Quantity = 20 },
+ new() { Id = 3, ProductName = "Product C", Quantity = 30 }
+ };
+
+ // Build query: .Where(x => x.Id > 1).Count()
+ // The Count() is an aggregate that should execute on the server!
+ var clientQuery = new List().AsQueryable()
+ .Where(x => x.Id > 1);
+
+ // Build the Count() call expression manually
+ var countExpression = System.Linq.Expressions.Expression.Call(
+ typeof(Queryable),
+ nameof(Queryable.Count),
+ [typeof(TestOrderItem)],
+ clientQuery.Expression
+ );
+
+ var queryNode = AcExpressionConverter.ToNode(countExpression);
+
+ // Execute on server - returns just the count, NOT the full list!
+ var serverQuery = serverItems.AsQueryable();
+ var result = AcSerializerCommon.ExecuteQueryFromNode(serverQuery, queryNode);
+
+ // Assert - Count should be 2 (Id=2 and Id=3)
+ Assert.AreEqual(2, result);
+ }
+
+ [TestMethod]
+ public void QueryableAggregate_CountWithPredicate_ExecutesOnServer()
+ {
+ // Arrange - Server data
+ var serverItems = new List
+ {
+ new() { Id = 1, ProductName = "Product A", Quantity = 10 },
+ new() { Id = 2, ProductName = "Product B", Quantity = 20 },
+ new() { Id = 3, ProductName = "Product C", Quantity = 30 }
+ };
+
+ // Build query: .Count(x => x.Quantity > 15)
+ var clientQuery = new List().AsQueryable();
+ Expression> predicate = x => x.Quantity > 15;
+
+ var countExpression = System.Linq.Expressions.Expression.Call(
+ typeof(Queryable),
+ nameof(Queryable.Count),
+ [typeof(TestOrderItem)],
+ clientQuery.Expression,
+ System.Linq.Expressions.Expression.Quote(predicate)
+ );
+
+ var queryNode = AcExpressionConverter.ToNode(countExpression);
+
+ // Execute on server
+ var serverQuery = serverItems.AsQueryable();
+ var result = AcSerializerCommon.ExecuteQueryFromNode(serverQuery, queryNode);
+
+ // Assert - Count should be 2 (Quantity 20 and 30 are > 15)
+ Assert.AreEqual(2, result);
+ }
+
+ [TestMethod]
+ public void QueryableAggregate_Any_ExecutesOnServer()
+ {
+ // Arrange - Server data
+ var serverItems = new List
+ {
+ new() { Id = 1, ProductName = "Product A", Quantity = 10 },
+ new() { Id = 2, ProductName = "Product B", Quantity = 20 },
+ new() { Id = 3, ProductName = "Product C", Quantity = 30 }
+ };
+
+ // Build query: .Any(x => x.Quantity > 25)
+ var clientQuery = new List().AsQueryable();
+ Expression> predicate = x => x.Quantity > 25;
+
+ var anyExpression = System.Linq.Expressions.Expression.Call(
+ typeof(Queryable),
+ nameof(Queryable.Any),
+ [typeof(TestOrderItem)],
+ clientQuery.Expression,
+ System.Linq.Expressions.Expression.Quote(predicate)
+ );
+
+ var queryNode = AcExpressionConverter.ToNode(anyExpression);
+
+ // Execute on server
+ var serverQuery = serverItems.AsQueryable();
+ var result = AcSerializerCommon.ExecuteQueryFromNode(serverQuery, queryNode);
+
+ // Assert - Any should be true (Quantity 30 is > 25)
+ Assert.AreEqual(true, result);
+ }
+
+ [TestMethod]
+ public void QueryableAggregate_Sum_ExecutesOnServer()
+ {
+ // Arrange - Server data
+ var serverItems = new List
+ {
+ new() { Id = 1, ProductName = "Product A", Quantity = 10, UnitPrice = 100m },
+ new() { Id = 2, ProductName = "Product B", Quantity = 20, UnitPrice = 200m },
+ new() { Id = 3, ProductName = "Product C", Quantity = 30, UnitPrice = 300m }
+ };
+
+ // Build query: .Sum(x => x.Quantity)
+ var clientQuery = new List().AsQueryable();
+ Expression> selector = x => x.Quantity;
+
+ var sumExpression = System.Linq.Expressions.Expression.Call(
+ typeof(Queryable),
+ nameof(Queryable.Sum),
+ [typeof(TestOrderItem)],
+ clientQuery.Expression,
+ System.Linq.Expressions.Expression.Quote(selector)
+ );
+
+ var queryNode = AcExpressionConverter.ToNode(sumExpression);
+
+ // Execute on server
+ var serverQuery = serverItems.AsQueryable();
+ var result = AcSerializerCommon.ExecuteQueryFromNode(serverQuery, queryNode);
+
+ // Assert - Sum should be 60 (10 + 20 + 30)
+ Assert.AreEqual(60, result);
+ }
+
+ #endregion
+
+ #region IQueryable Diagnostics Tests
+
+ [TestMethod]
+ public void QueryableExpression_WhenSerialized_HasCorrectStructure()
+ {
+ // Arrange - Create an IQueryable with Where
+ var query = new List().AsQueryable().Where(x => x.Id > 1);
+
+ // Act - Serialize the expression
+ var queryNode = AcExpressionConverter.ToNode(query.Expression);
+
+ // Assert - Check structure
+ Console.WriteLine($"NodeType: {queryNode.NodeType}");
+ Console.WriteLine($"MethodName: {queryNode.MethodName}");
+ Console.WriteLine($"DeclaringType: {queryNode.DeclaringType}");
+ Console.WriteLine($"GenericArguments: {string.Join(", ", queryNode.GenericArguments ?? [])}");
+ Console.WriteLine($"Arguments count: {queryNode.Arguments?.Count}");
+
+ Assert.AreEqual(System.Linq.Expressions.ExpressionType.Call, queryNode.NodeType);
+ Assert.AreEqual("Where", queryNode.MethodName);
+ Assert.AreEqual(2, queryNode.Arguments?.Count);
+
+ // First argument should be the source (Constant with null value for IQueryable)
+ var firstArg = queryNode.Arguments![0];
+ Console.WriteLine($"First arg NodeType: {firstArg.NodeType}");
+ Console.WriteLine($"First arg ValueType: {firstArg.ValueType}");
+ Assert.AreEqual(System.Linq.Expressions.ExpressionType.Constant, firstArg.NodeType);
+
+ // Second argument should be the Quote
+ var secondArg = queryNode.Arguments![1];
+ Console.WriteLine($"Second arg NodeType: {secondArg.NodeType}");
+ Assert.AreEqual(System.Linq.Expressions.ExpressionType.Quote, secondArg.NodeType);
+
+ // Quote's Operand should be the Lambda
+ Console.WriteLine($"Quote Operand is null: {secondArg.Operand == null}");
+ Assert.IsNotNull(secondArg.Operand, "Quote should have Operand");
+ Console.WriteLine($"Quote Operand NodeType: {secondArg.Operand!.NodeType}");
+ Assert.AreEqual(System.Linq.Expressions.ExpressionType.Lambda, secondArg.Operand.NodeType);
+ }
+
+ [TestMethod]
+ public void QueryableExpression_ApplyToServer_Works()
+ {
+ // Arrange - Client query
+ var clientQuery = new List().AsQueryable().Where(x => x.Id > 1);
+ var queryNode = AcExpressionConverter.ToNode(clientQuery.Expression);
+
+ // Server data
+ var serverItems = new List
+ {
+ new() { Id = 1, ProductName = "Product A" },
+ new() { Id = 2, ProductName = "Product B" },
+ new() { Id = 3, ProductName = "Product C" }
+ };
+ var serverQuery = serverItems.AsQueryable();
+
+ // Act - Apply the query expression to server data
+ var result = AcSerializerCommon.ApplyQueryFromNode(serverQuery, queryNode);
+ var resultList = result.ToList();
+
+ // Assert
+ Assert.AreEqual(2, resultList.Count);
+ Assert.IsTrue(resultList.All(x => x.Id > 1));
+ }
+
+ #endregion
+}
diff --git a/AyCode.Services.Server.Tests/SignalRs/SignalRDatasources/SignalRDataSourceTestBase.CrudOperations.cs b/AyCode.Services.Server.Tests/SignalRs/SignalRDatasources/SignalRDataSourceTestBase.CrudOperations.cs
new file mode 100644
index 0000000..d566ac5
--- /dev/null
+++ b/AyCode.Services.Server.Tests/SignalRs/SignalRDatasources/SignalRDataSourceTestBase.CrudOperations.cs
@@ -0,0 +1,282 @@
+using AyCode.Core.Enums;
+using AyCode.Core.Tests.TestModels;
+
+namespace AyCode.Services.Server.Tests.SignalRs.SignalRDatasources;
+
+public abstract partial class SignalRDataSourceTestBase
+{
+ #region Add Tests
+
+ [TestMethod]
+ public virtual async Task Add_WithAutoSave_AddsItem()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+ var newItem = new TestOrderItem { Id = 100, ProductName = "New Product", Quantity = 5, UnitPrice = 50m };
+ var result = await dataSource.Add(newItem, autoSave: true);
+
+ Assert.AreEqual(1, dataSource.Count);
+ Assert.AreEqual("New Product", result.ProductName);
+ Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
+ }
+
+ [TestMethod]
+ public virtual void Add_WithoutAutoSave_AddsToTrackingOnly()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+ var newItem = new TestOrderItem { Id = 100, ProductName = "New Product" };
+ dataSource.Add(newItem);
+
+ Assert.AreEqual(1, dataSource.Count);
+ Assert.AreEqual(1, dataSource.GetTrackingItems().Count);
+ Assert.AreEqual(TrackingState.Add, dataSource.GetTrackingItems()[0].TrackingState);
+ }
+
+ [TestMethod]
+ public virtual void Add_DuplicateId_ThrowsException()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+ dataSource.Add(new TestOrderItem { Id = 100, ProductName = "First" });
+
+ Assert.ThrowsExactly(() =>
+ {
+ dataSource.Add(new TestOrderItem { Id = 100, ProductName = "Duplicate" });
+ });
+ }
+
+ [TestMethod]
+ public virtual void Add_DefaultId_ThrowsException()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+
+ Assert.ThrowsExactly(() =>
+ {
+ dataSource.Add(new TestOrderItem { Id = 0, ProductName = "Invalid" });
+ });
+ }
+
+ [TestMethod]
+ public virtual void AddRange_AddsMultipleItems()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+
+ var items = new[]
+ {
+ new TestOrderItem { Id = 101, ProductName = "Item 1" },
+ new TestOrderItem { Id = 102, ProductName = "Item 2" },
+ new TestOrderItem { Id = 103, ProductName = "Item 3" }
+ };
+ dataSource.AddRange(items);
+ Assert.AreEqual(3, dataSource.Count);
+ }
+
+ #endregion
+
+ #region AddOrUpdate Tests
+
+ [TestMethod]
+ public virtual async Task AddOrUpdate_AddsNew_WhenNotExists()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+ var newItem = new TestOrderItem { Id = 200, ProductName = "Brand New" };
+ var result = await dataSource.AddOrUpdate(newItem, autoSave: true);
+
+ Assert.AreEqual(1, dataSource.Count);
+ Assert.AreEqual("Brand New", result.ProductName);
+ }
+
+ [TestMethod]
+ public virtual async Task AddOrUpdate_UpdatesExisting_WhenExists()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+ await dataSource.LoadDataSource();
+
+ var existingId = dataSource[0].Id;
+ var updatedItem = new TestOrderItem { Id = existingId, ProductName = "Updated Name", Quantity = 999 };
+ _ = await dataSource.AddOrUpdate(updatedItem, autoSave: true);
+
+ Assert.AreEqual(3, dataSource.Count);
+ Assert.AreEqual("Updated Name", dataSource[0].ProductName);
+ }
+
+ #endregion
+
+ #region Insert Tests
+
+ [TestMethod]
+ public virtual void Insert_AtIndex_InsertsCorrectly()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+
+ dataSource.Add(new TestOrderItem { Id = 1, ProductName = "First" });
+ dataSource.Add(new TestOrderItem { Id = 3, ProductName = "Third" });
+ dataSource.Insert(1, new TestOrderItem { Id = 2, ProductName = "Second" });
+
+ Assert.AreEqual(3, dataSource.Count);
+ Assert.AreEqual("Second", dataSource[1].ProductName);
+ Assert.AreEqual(3, dataSource.GetTrackingItems().Count);
+ }
+
+ [TestMethod]
+ public virtual async Task Insert_WithAutoSave_SavesImmediately()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+
+ var newItem = new TestOrderItem { Id = 500, ProductName = "Inserted" };
+ _ = await dataSource.Insert(0, newItem, autoSave: true);
+
+ Assert.AreEqual(1, dataSource.Count);
+ Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
+ }
+
+ #endregion
+
+ #region Update Tests
+
+ [TestMethod]
+ public virtual async Task Update_ByIndex_UpdatesCorrectly()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+ await dataSource.LoadDataSource();
+
+ var updatedItem = new TestOrderItem
+ {
+ Id = dataSource[0].Id,
+ ProductName = "Updated Product",
+ Quantity = 100
+ };
+
+ _ = await dataSource.Update(0, updatedItem, autoSave: true);
+
+ Assert.AreEqual("Updated Product", dataSource[0].ProductName);
+ Assert.AreEqual(100, dataSource[0].Quantity);
+ }
+
+ [TestMethod]
+ public virtual async Task Update_ByItem_UpdatesCorrectly()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+ await dataSource.LoadDataSource();
+
+ var updatedItem = new TestOrderItem
+ {
+ Id = dataSource[1].Id,
+ ProductName = "Updated B",
+ Quantity = 50
+ };
+
+ _ = await dataSource.Update(updatedItem, autoSave: true);
+
+ Assert.AreEqual("Updated B", dataSource[1].ProductName);
+ }
+
+ [TestMethod]
+ public virtual void Indexer_Set_TracksUpdate()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+
+ dataSource.Add(new TestOrderItem { Id = 1, ProductName = "Original" });
+ dataSource[0] = new TestOrderItem { Id = 1, ProductName = "Modified" };
+
+ Assert.AreEqual(1, dataSource.GetTrackingItems().Count);
+ Assert.AreEqual(TrackingState.Add, dataSource.GetTrackingItems()[0].TrackingState);
+ }
+
+ #endregion
+
+ #region Remove Tests
+
+ [TestMethod]
+ public virtual async Task Remove_ById_RemovesItem()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+ await dataSource.LoadDataSource();
+
+ var idToRemove = dataSource[0].Id;
+ var result = await dataSource.Remove(idToRemove, autoSave: true);
+
+ Assert.IsTrue(result);
+ Assert.AreEqual(2, dataSource.Count);
+ }
+
+ [TestMethod]
+ public virtual async Task Remove_ByItem_RemovesItem()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+ await dataSource.LoadDataSource();
+
+ var itemToRemove = dataSource[1];
+ var result = await dataSource.Remove(itemToRemove, autoSave: true);
+
+ Assert.IsTrue(result);
+ Assert.AreEqual(2, dataSource.Count);
+ }
+
+ [TestMethod]
+ public virtual void Remove_WithoutAutoSave_TracksRemoval()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+
+ dataSource.Add(new TestOrderItem { Id = 1, ProductName = "ToRemove" });
+
+ dataSource.GetTrackingItems().Clear();
+ dataSource.Remove(dataSource[0]);
+
+ Assert.AreEqual(0, dataSource.Count);
+ Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
+ Assert.AreEqual(TrackingState.Remove, dataSource.GetTrackingItems()[0].TrackingState);
+ }
+
+ [TestMethod]
+ public virtual void RemoveAt_RemovesCorrectItem()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+
+ dataSource.Add(new TestOrderItem { Id = 1, ProductName = "First" });
+ dataSource.Add(new TestOrderItem { Id = 2, ProductName = "Second" });
+ dataSource.Add(new TestOrderItem { Id = 3, ProductName = "Third" });
+
+ dataSource.GetTrackingItems().Clear();
+ dataSource.RemoveAt(1);
+
+ Assert.AreEqual(2, dataSource.Count);
+ Assert.AreEqual("First", dataSource[0].ProductName);
+ Assert.AreEqual("Third", dataSource[1].ProductName);
+ }
+
+ [TestMethod]
+ public virtual async Task RemoveAt_WithAutoSave_SavesImmediately()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+
+ await dataSource.LoadDataSource();
+ await dataSource.RemoveAt(0, autoSave: true);
+
+ Assert.AreEqual(2, dataSource.Count);
+ Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
+ }
+
+ [TestMethod]
+ public virtual void TryRemove_ReturnsTrue_WhenExists()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+
+ dataSource.Add(new TestOrderItem { Id = 1, ProductName = "Test" });
+ var result = dataSource.TryRemove(1, out var removedItem);
+
+ Assert.IsTrue(result);
+ Assert.IsNotNull(removedItem);
+ Assert.AreEqual("Test", removedItem.ProductName);
+ }
+
+ [TestMethod]
+ public virtual void TryRemove_ReturnsFalse_WhenNotExists()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+ var result = dataSource.TryRemove(9999, out var removedItem);
+
+ Assert.IsFalse(result);
+ Assert.IsNull(removedItem);
+ }
+
+ #endregion
+}
diff --git a/AyCode.Services.Server.Tests/SignalRs/SignalRDatasources/SignalRDataSourceTestBase.LoadDataSource.cs b/AyCode.Services.Server.Tests/SignalRs/SignalRDatasources/SignalRDataSourceTestBase.LoadDataSource.cs
new file mode 100644
index 0000000..d9da2e4
--- /dev/null
+++ b/AyCode.Services.Server.Tests/SignalRs/SignalRDatasources/SignalRDataSourceTestBase.LoadDataSource.cs
@@ -0,0 +1,96 @@
+using AyCode.Core.Helpers;
+using AyCode.Core.Tests.TestModels;
+
+namespace AyCode.Services.Server.Tests.SignalRs.SignalRDatasources;
+
+public abstract partial class SignalRDataSourceTestBase
+{
+ #region LoadDataSource Tests
+
+ [TestMethod]
+ public virtual async Task LoadDataSource_ReturnsAllItems()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+ await dataSource.LoadDataSource();
+
+ Assert.AreEqual(3, dataSource.Count);
+ Assert.AreEqual("Product A", dataSource[0].ProductName);
+ Assert.AreEqual("Product B", dataSource[1].ProductName);
+ Assert.AreEqual("Product C", dataSource[2].ProductName);
+ }
+
+ [TestMethod]
+ public virtual async Task LoadDataSource_ClearsChangeTracking_ByDefault()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+ dataSource.Add(new TestOrderItem { Id = 999, ProductName = "Tracked" });
+
+ Assert.AreEqual(1, dataSource.GetTrackingItems().Count);
+
+ await dataSource.LoadDataSource();
+ Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
+ }
+
+ [TestMethod]
+ public virtual async Task LoadDataSource_PreservesChangeTracking_WhenFalse()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+ await dataSource.LoadDataSource();
+
+ dataSource.Add(new TestOrderItem { Id = 999, ProductName = "Tracked" });
+ await dataSource.LoadDataSource(clearChangeTracking: false);
+
+ Assert.AreEqual(1, dataSource.GetTrackingItems().Count);
+ }
+
+ [TestMethod]
+ public virtual async Task LoadDataSource_InvokesOnDataSourceLoaded()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+
+ var callbackInvoked = false;
+ dataSource.OnDataSourceLoaded = () => { callbackInvoked = true; return Task.CompletedTask; };
+ await dataSource.LoadDataSource();
+
+ Assert.IsTrue(callbackInvoked);
+ }
+
+ [TestMethod]
+ public virtual async Task LoadDataSource_MultipleCalls_RefreshesData()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+
+ await dataSource.LoadDataSource();
+ var firstCount = dataSource.Count;
+
+ await dataSource.LoadDataSource();
+ var secondCount = dataSource.Count;
+
+ Assert.AreEqual(firstCount, secondCount);
+ Assert.AreEqual(3, secondCount);
+ }
+
+ #endregion
+
+ #region LoadDataSourceAsync Tests
+
+ [TestMethod]
+ public virtual async Task LoadDataSourceAsync_LoadsDataViaCallback()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+
+ var loadCompleted = false;
+ dataSource.OnDataSourceLoaded = () =>
+ {
+ loadCompleted = true;
+ return Task.CompletedTask;
+ };
+
+ await dataSource.LoadDataSourceAsync();
+
+ Assert.IsTrue(TaskHelper.WaitTo(() => loadCompleted, 5000));
+ Assert.AreEqual(3, dataSource.Count);
+ }
+
+ #endregion
+}
diff --git a/AyCode.Services.Server.Tests/SignalRs/SignalRDatasources/SignalRDataSourceTestBase.LoadItem.cs b/AyCode.Services.Server.Tests/SignalRs/SignalRDatasources/SignalRDataSourceTestBase.LoadItem.cs
new file mode 100644
index 0000000..0d3cca4
--- /dev/null
+++ b/AyCode.Services.Server.Tests/SignalRs/SignalRDatasources/SignalRDataSourceTestBase.LoadItem.cs
@@ -0,0 +1,72 @@
+using AyCode.Core.Enums;
+using AyCode.Core.Tests.TestModels;
+using AyCode.Services.Server.SignalRs;
+
+namespace AyCode.Services.Server.Tests.SignalRs.SignalRDatasources;
+
+public abstract partial class SignalRDataSourceTestBase
+{
+ #region LoadItem Tests
+
+ [TestMethod]
+ public virtual async Task LoadItem_ReturnsSingleItem()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+ var result = await dataSource.LoadItem(2);
+
+ Assert.IsNotNull(result);
+ Assert.AreEqual(2, result.Id);
+ Assert.AreEqual("Product B", result.ProductName);
+ }
+
+ [TestMethod]
+ public virtual async Task LoadItem_AddsToDataSource_WhenNotExists()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+ Assert.AreEqual(0, dataSource.Count);
+
+ await dataSource.LoadItem(1);
+
+ Assert.AreEqual(1, dataSource.Count);
+ Assert.AreEqual("Product A", dataSource[0].ProductName);
+ }
+
+ [TestMethod]
+ public virtual async Task LoadItem_UpdatesExisting_WhenAlreadyLoaded()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+ await dataSource.LoadDataSource();
+
+ var originalItem = dataSource[0];
+ var reloaded = await dataSource.LoadItem(originalItem.Id);
+
+ Assert.AreEqual(3, dataSource.Count);
+ Assert.IsNotNull(reloaded);
+ }
+
+ [TestMethod]
+ public virtual async Task LoadItem_InvokesOnDataSourceItemChanged()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+
+ ItemChangedEventArgs? eventArgs = null;
+
+ dataSource.OnDataSourceItemChanged = args => { eventArgs = args; return Task.CompletedTask; };
+ await dataSource.LoadItem(1);
+
+ Assert.IsNotNull(eventArgs);
+ Assert.AreEqual(TrackingState.Get, eventArgs.TrackingState);
+ Assert.AreEqual(1, eventArgs.Item.Id);
+ }
+
+ [TestMethod]
+ public virtual async Task LoadItem_ReturnsNull_WhenNotFound()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+ var result = await dataSource.LoadItem(9999);
+
+ Assert.IsNull(result);
+ }
+
+ #endregion
+}
diff --git a/AyCode.Services.Server.Tests/SignalRs/SignalRDatasources/SignalRDataSourceTestBase.SaveChanges.cs b/AyCode.Services.Server.Tests/SignalRs/SignalRDatasources/SignalRDataSourceTestBase.SaveChanges.cs
new file mode 100644
index 0000000..6e68427
--- /dev/null
+++ b/AyCode.Services.Server.Tests/SignalRs/SignalRDatasources/SignalRDataSourceTestBase.SaveChanges.cs
@@ -0,0 +1,60 @@
+using AyCode.Core.Enums;
+using AyCode.Core.Tests.TestModels;
+
+namespace AyCode.Services.Server.Tests.SignalRs.SignalRDatasources;
+
+public abstract partial class SignalRDataSourceTestBase
+{
+ #region SaveChanges Tests
+
+ [TestMethod]
+ public virtual async Task SaveChanges_SavesTrackedItems()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+
+ dataSource.Add(new TestOrderItem { Id = 101, ProductName = "Item 1" });
+ dataSource.Add(new TestOrderItem { Id = 102, ProductName = "Item 2" });
+
+ var unsaved = await dataSource.SaveChanges();
+
+ Assert.AreEqual(0, unsaved.Count);
+ Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
+ }
+
+ [TestMethod]
+ public virtual async Task SaveChangesAsync_ClearsTracking()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+ dataSource.Add(new TestOrderItem { Id = 103, ProductName = "Item 3" });
+
+ await dataSource.SaveChangesAsync();
+
+ Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
+ }
+
+ [TestMethod]
+ public virtual async Task SaveItem_ById_SavesSpecificItem()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+
+ dataSource.Add(new TestOrderItem { Id = 201, ProductName = "Specific" });
+ var result = await dataSource.SaveItem(201);
+
+ Assert.AreEqual("Specific", result.ProductName);
+ Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
+ }
+
+ [TestMethod]
+ public virtual async Task SaveItem_WithTrackingState_SavesCorrectly()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+ await dataSource.LoadDataSource();
+
+ var item = dataSource[0];
+ var result = await dataSource.SaveItem(item, TrackingState.Update);
+
+ Assert.IsNotNull(result);
+ }
+
+ #endregion
+}
diff --git a/AyCode.Services.Server.Tests/SignalRs/SignalRDatasources/SignalRDataSourceTestBase.Tracking.cs b/AyCode.Services.Server.Tests/SignalRs/SignalRDatasources/SignalRDataSourceTestBase.Tracking.cs
new file mode 100644
index 0000000..41d10d0
--- /dev/null
+++ b/AyCode.Services.Server.Tests/SignalRs/SignalRDatasources/SignalRDataSourceTestBase.Tracking.cs
@@ -0,0 +1,102 @@
+using AyCode.Core.Enums;
+using AyCode.Core.Tests.TestModels;
+
+namespace AyCode.Services.Server.Tests.SignalRs.SignalRDatasources;
+
+public abstract partial class SignalRDataSourceTestBase
+{
+ #region Tracking Tests
+
+ [TestMethod]
+ public virtual void SetTrackingStateToUpdate_MarksItemForUpdate()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+
+ dataSource.Add(new TestOrderItem { Id = 1, ProductName = "Test" });
+
+ dataSource.SetTrackingStateToUpdate(dataSource[0]);
+
+ Assert.AreEqual(1, dataSource.GetTrackingItems().Count);
+ Assert.AreEqual(TrackingState.Update, dataSource.GetTrackingItems()[0].TrackingState);
+ }
+
+ [TestMethod]
+ public virtual void SetTrackingStateToUpdate_DoesNotChangeAddState()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+
+ dataSource.Add(new TestOrderItem { Id = 1, ProductName = "New Item" });
+ dataSource.SetTrackingStateToUpdate(dataSource[0]);
+
+ Assert.AreEqual(TrackingState.Add, dataSource.GetTrackingItems()[0].TrackingState);
+ }
+
+ [TestMethod]
+ public virtual void TryGetTrackingItem_ReturnsTrue_WhenTracked()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+
+ dataSource.Add(new TestOrderItem { Id = 1, ProductName = "Tracked" });
+ var result = dataSource.TryGetTrackingItem(1, out var trackingItem);
+
+ Assert.IsTrue(result);
+ Assert.IsNotNull(trackingItem);
+ Assert.AreEqual(TrackingState.Add, trackingItem.TrackingState);
+ }
+
+ [TestMethod]
+ public virtual void TryGetTrackingItem_ReturnsFalse_WhenNotTracked()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+ var result = dataSource.TryGetTrackingItem(9999, out var trackingItem);
+
+ Assert.IsFalse(result);
+ Assert.IsNull(trackingItem);
+ }
+
+ #endregion
+
+ #region Rollback Tests
+
+ [TestMethod]
+ public virtual void TryRollbackItem_RevertsAddedItem()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+
+ dataSource.Add(new TestOrderItem { Id = 1, ProductName = "Added" });
+ var result = dataSource.TryRollbackItem(1, out var originalValue);
+
+ Assert.IsTrue(result);
+ Assert.AreEqual(0, dataSource.Count);
+ Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
+ }
+
+ [TestMethod]
+ public virtual async Task TryRollbackItem_RevertsUpdatedItem()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+ await dataSource.LoadDataSource();
+
+ var originalName = dataSource[0].ProductName;
+ dataSource[0] = new TestOrderItem { Id = dataSource[0].Id, ProductName = "Changed" };
+ var result = dataSource.TryRollbackItem(dataSource[0].Id, out var originalValue);
+
+ Assert.IsTrue(result);
+ Assert.IsNotNull(originalValue);
+ }
+
+ [TestMethod]
+ public virtual void Rollback_RevertsAllChanges()
+ {
+ var dataSource = CreateDataSource(_client, _crudTags);
+
+ dataSource.Add(new TestOrderItem { Id = 1, ProductName = "Item1" });
+ dataSource.Add(new TestOrderItem { Id = 2, ProductName = "Item2" });
+ dataSource.Rollback();
+
+ Assert.AreEqual(0, dataSource.Count);
+ Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
+ }
+
+ #endregion
+}
diff --git a/AyCode.Services.Server.Tests/SignalRs/SignalRDatasources/SignalRDataSourceTestBase.cs b/AyCode.Services.Server.Tests/SignalRs/SignalRDatasources/SignalRDataSourceTestBase.cs
index f55a2ec..9228ae5 100644
--- a/AyCode.Services.Server.Tests/SignalRs/SignalRDatasources/SignalRDataSourceTestBase.cs
+++ b/AyCode.Services.Server.Tests/SignalRs/SignalRDatasources/SignalRDataSourceTestBase.cs
@@ -1,14 +1,10 @@
-using System.Collections;
-using AyCode.Core.Enums;
-using AyCode.Core.Extensions;
-using AyCode.Core.Helpers;
using AyCode.Core.Serializers.Jsons;
using AyCode.Core.Tests.TestModels;
using AyCode.Services.Server.SignalRs;
using AyCode.Services.SignalRs;
+
namespace AyCode.Services.Server.Tests.SignalRs.SignalRDatasources;
-#region Abstract Test Base
///
/// Base class for SignalR DataSource tests with full round-trip coverage.
/// Tests the complete path: DataSource -> SignalRClient -> SignalRHub -> Service -> Response -> SignalRClient -> DataSource
@@ -16,17 +12,19 @@ namespace AyCode.Services.Server.Tests.SignalRs.SignalRDatasources;
///
/// The concrete DataSource type
/// The inner list type (List or AcObservableCollection)
-public abstract class SignalRDataSourceTestBase
+public abstract partial class SignalRDataSourceTestBase
where TDataSource : AcSignalRDataSource
where TIList : class, IList
{
protected abstract AcSerializerOptions SerializerOption { get; }
protected abstract TDataSource CreateDataSource(TestableSignalRClient2 client, SignalRCrudTags crudTags);
+
protected TestLogger _logger = null!;
protected TestableSignalRHub2 _hub = null!;
protected TestableSignalRClient2 _client = null!;
protected TestSignalRService2 _service = null!;
protected SignalRCrudTags _crudTags = null!;
+
[TestInitialize]
public void Setup()
{
@@ -47,935 +45,4 @@ public abstract class SignalRDataSourceTestBase
TestSignalRTags.DataSourceRemove
);
}
-
- #region LoadDataSource Tests
- [TestMethod]
- public virtual async Task LoadDataSource_ReturnsAllItems()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
- await dataSource.LoadDataSource();
-
- Assert.AreEqual(3, dataSource.Count);
- Assert.AreEqual("Product A", dataSource[0].ProductName);
- Assert.AreEqual("Product B", dataSource[1].ProductName);
- Assert.AreEqual("Product C", dataSource[2].ProductName);
- }
-
- [TestMethod]
- public virtual async Task LoadDataSource_ClearsChangeTracking_ByDefault()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
- dataSource.Add(new TestOrderItem { Id = 999, ProductName = "Tracked" });
-
- Assert.AreEqual(1, dataSource.GetTrackingItems().Count);
-
- await dataSource.LoadDataSource();
- Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
- }
-
- [TestMethod]
- public virtual async Task LoadDataSource_PreservesChangeTracking_WhenFalse()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
- await dataSource.LoadDataSource();
-
- dataSource.Add(new TestOrderItem { Id = 999, ProductName = "Tracked" });
- await dataSource.LoadDataSource(clearChangeTracking: false);
-
- Assert.AreEqual(1, dataSource.GetTrackingItems().Count);
- }
-
- [TestMethod]
- public virtual async Task LoadDataSource_InvokesOnDataSourceLoaded()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
-
- var callbackInvoked = false;
- dataSource.OnDataSourceLoaded = () => { callbackInvoked = true; return Task.CompletedTask; };
- await dataSource.LoadDataSource();
-
- Assert.IsTrue(callbackInvoked);
- }
-
- [TestMethod]
- public virtual async Task LoadDataSource_MultipleCalls_RefreshesData()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
-
- await dataSource.LoadDataSource();
- var firstCount = dataSource.Count;
-
- await dataSource.LoadDataSource();
- var secondCount = dataSource.Count;
-
- Assert.AreEqual(firstCount, secondCount);
- Assert.AreEqual(3, secondCount);
- }
-
- #endregion
- #region LoadDataSourceAsync Tests
-
- [TestMethod]
- public virtual async Task LoadDataSourceAsync_LoadsDataViaCallback()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
-
- var loadCompleted = false;
- dataSource.OnDataSourceLoaded = () =>
- {
- loadCompleted = true;
- return Task.CompletedTask;
- };
-
- await dataSource.LoadDataSourceAsync();
-
- Assert.IsTrue(TaskHelper.WaitTo(() => loadCompleted, 5000));
- Assert.AreEqual(3, dataSource.Count);
- }
-
- #endregion
- #region LoadItem Tests
- [TestMethod]
- public virtual async Task LoadItem_ReturnsSingleItem()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
- var result = await dataSource.LoadItem(2);
-
- Assert.IsNotNull(result);
- Assert.AreEqual(2, result.Id);
- Assert.AreEqual("Product B", result.ProductName);
- }
-
- [TestMethod]
- public virtual async Task LoadItem_AddsToDataSource_WhenNotExists()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
- Assert.AreEqual(0, dataSource.Count);
-
- await dataSource.LoadItem(1);
-
- Assert.AreEqual(1, dataSource.Count);
- Assert.AreEqual("Product A", dataSource[0].ProductName);
- }
-
- [TestMethod]
- public virtual async Task LoadItem_UpdatesExisting_WhenAlreadyLoaded()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
- await dataSource.LoadDataSource();
-
- var originalItem = dataSource[0];
- var reloaded = await dataSource.LoadItem(originalItem.Id);
-
- Assert.AreEqual(3, dataSource.Count);
- Assert.IsNotNull(reloaded);
- }
-
- [TestMethod]
- public virtual async Task LoadItem_InvokesOnDataSourceItemChanged()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
-
- ItemChangedEventArgs? eventArgs = null;
-
- dataSource.OnDataSourceItemChanged = args => { eventArgs = args; return Task.CompletedTask; };
- await dataSource.LoadItem(1);
-
- Assert.IsNotNull(eventArgs);
- Assert.AreEqual(TrackingState.Get, eventArgs.TrackingState);
- Assert.AreEqual(1, eventArgs.Item.Id);
- }
-
- [TestMethod]
- public virtual async Task LoadItem_ReturnsNull_WhenNotFound()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
- var result = await dataSource.LoadItem(9999);
-
- Assert.IsNull(result);
- }
- #endregion
- #region Add Tests
- [TestMethod]
- public virtual async Task Add_WithAutoSave_AddsItem()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
- var newItem = new TestOrderItem { Id = 100, ProductName = "New Product", Quantity = 5, UnitPrice = 50m };
- var result = await dataSource.Add(newItem, autoSave: true);
-
- Assert.AreEqual(1, dataSource.Count);
- Assert.AreEqual("New Product", result.ProductName);
- Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
- }
-
- [TestMethod]
- public virtual void Add_WithoutAutoSave_AddsToTrackingOnly()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
- var newItem = new TestOrderItem { Id = 100, ProductName = "New Product" };
- dataSource.Add(newItem);
-
- Assert.AreEqual(1, dataSource.Count);
- Assert.AreEqual(1, dataSource.GetTrackingItems().Count);
- Assert.AreEqual(TrackingState.Add, dataSource.GetTrackingItems()[0].TrackingState);
- }
-
- [TestMethod]
- public virtual void Add_DuplicateId_ThrowsException()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
- dataSource.Add(new TestOrderItem { Id = 100, ProductName = "First" });
-
- Assert.ThrowsExactly(() =>
- {
- dataSource.Add(new TestOrderItem { Id = 100, ProductName = "Duplicate" });
- });
- }
-
- [TestMethod]
- public virtual void Add_DefaultId_ThrowsException()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
-
- Assert.ThrowsExactly(() =>
- {
- dataSource.Add(new TestOrderItem { Id = 0, ProductName = "Invalid" });
- });
- }
-
- [TestMethod]
- public virtual void AddRange_AddsMultipleItems()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
-
- var items = new[]
- {
- new TestOrderItem { Id = 101, ProductName = "Item 1" },
- new TestOrderItem { Id = 102, ProductName = "Item 2" },
- new TestOrderItem { Id = 103, ProductName = "Item 3" }
- };
- dataSource.AddRange(items);
- Assert.AreEqual(3, dataSource.Count);
- }
- #endregion
- #region AddOrUpdate Tests
- [TestMethod]
- public virtual async Task AddOrUpdate_AddsNew_WhenNotExists()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
- var newItem = new TestOrderItem { Id = 200, ProductName = "Brand New" };
- var result = await dataSource.AddOrUpdate(newItem, autoSave: true);
-
- Assert.AreEqual(1, dataSource.Count);
- Assert.AreEqual("Brand New", result.ProductName);
- }
-
- [TestMethod]
- public virtual async Task AddOrUpdate_UpdatesExisting_WhenExists()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
- await dataSource.LoadDataSource();
-
- var existingId = dataSource[0].Id;
- var updatedItem = new TestOrderItem { Id = existingId, ProductName = "Updated Name", Quantity = 999 };
- _ = await dataSource.AddOrUpdate(updatedItem, autoSave: true);
-
- Assert.AreEqual(3, dataSource.Count);
- Assert.AreEqual("Updated Name", dataSource[0].ProductName);
- }
- #endregion
- #region Insert Tests
- [TestMethod]
- public virtual void Insert_AtIndex_InsertsCorrectly()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
-
- dataSource.Add(new TestOrderItem { Id = 1, ProductName = "First" });
- dataSource.Add(new TestOrderItem { Id = 3, ProductName = "Third" });
- dataSource.Insert(1, new TestOrderItem { Id = 2, ProductName = "Second" });
-
- Assert.AreEqual(3, dataSource.Count);
- Assert.AreEqual("Second", dataSource[1].ProductName);
- Assert.AreEqual(3, dataSource.GetTrackingItems().Count);
- }
-
- [TestMethod]
- public virtual async Task Insert_WithAutoSave_SavesImmediately()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
-
- var newItem = new TestOrderItem { Id = 500, ProductName = "Inserted" };
- _ = await dataSource.Insert(0, newItem, autoSave: true);
-
- Assert.AreEqual(1, dataSource.Count);
- Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
- }
- #endregion
- #region Update Tests
- [TestMethod]
- public virtual async Task Update_ByIndex_UpdatesCorrectly()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
- await dataSource.LoadDataSource();
-
- var updatedItem = new TestOrderItem
- {
- Id = dataSource[0].Id,
- ProductName = "Updated Product",
- Quantity = 100
- };
-
- _ = await dataSource.Update(0, updatedItem, autoSave: true);
-
- Assert.AreEqual("Updated Product", dataSource[0].ProductName);
- Assert.AreEqual(100, dataSource[0].Quantity);
- }
-
- [TestMethod]
- public virtual async Task Update_ByItem_UpdatesCorrectly()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
- await dataSource.LoadDataSource();
-
- var updatedItem = new TestOrderItem
- {
- Id = dataSource[1].Id,
- ProductName = "Updated B",
- Quantity = 50
- };
-
- _ = await dataSource.Update(updatedItem, autoSave: true);
-
- Assert.AreEqual("Updated B", dataSource[1].ProductName);
- }
-
- [TestMethod]
- public virtual void Indexer_Set_TracksUpdate()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
-
- dataSource.Add(new TestOrderItem { Id = 1, ProductName = "Original" });
- dataSource[0] = new TestOrderItem { Id = 1, ProductName = "Modified" };
-
- Assert.AreEqual(1, dataSource.GetTrackingItems().Count);
- Assert.AreEqual(TrackingState.Add, dataSource.GetTrackingItems()[0].TrackingState);
- }
-
- #endregion
- #region Remove Tests
- [TestMethod]
- public virtual async Task Remove_ById_RemovesItem()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
- await dataSource.LoadDataSource();
-
- var idToRemove = dataSource[0].Id;
- var result = await dataSource.Remove(idToRemove, autoSave: true);
-
- Assert.IsTrue(result);
- Assert.AreEqual(2, dataSource.Count);
- }
-
- [TestMethod]
- public virtual async Task Remove_ByItem_RemovesItem()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
- await dataSource.LoadDataSource();
-
- var itemToRemove = dataSource[1];
- var result = await dataSource.Remove(itemToRemove, autoSave: true);
-
- Assert.IsTrue(result);
- Assert.AreEqual(2, dataSource.Count);
- }
-
- [TestMethod]
- public virtual void Remove_WithoutAutoSave_TracksRemoval()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
-
- dataSource.Add(new TestOrderItem { Id = 1, ProductName = "ToRemove" });
-
- dataSource.GetTrackingItems().Clear();
- dataSource.Remove(dataSource[0]);
-
- Assert.AreEqual(0, dataSource.Count);
- Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
- Assert.AreEqual(TrackingState.Remove, dataSource.GetTrackingItems()[0].TrackingState);
- }
-
- [TestMethod]
- public virtual void RemoveAt_RemovesCorrectItem()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
-
- dataSource.Add(new TestOrderItem { Id = 1, ProductName = "First" });
- dataSource.Add(new TestOrderItem { Id = 2, ProductName = "Second" });
- dataSource.Add(new TestOrderItem { Id = 3, ProductName = "Third" });
-
- dataSource.GetTrackingItems().Clear();
- dataSource.RemoveAt(1);
-
- Assert.AreEqual(2, dataSource.Count);
- Assert.AreEqual("First", dataSource[0].ProductName);
- Assert.AreEqual("Third", dataSource[1].ProductName);
- }
-
- [TestMethod]
- public virtual async Task RemoveAt_WithAutoSave_SavesImmediately()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
-
- await dataSource.LoadDataSource();
- await dataSource.RemoveAt(0, autoSave: true);
-
- Assert.AreEqual(2, dataSource.Count);
- Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
- }
-
- [TestMethod]
- public virtual void TryRemove_ReturnsTrue_WhenExists()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
-
- dataSource.Add(new TestOrderItem { Id = 1, ProductName = "Test" });
- var result = dataSource.TryRemove(1, out var removedItem);
-
- Assert.IsTrue(result);
- Assert.IsNotNull(removedItem);
- Assert.AreEqual("Test", removedItem.ProductName);
- }
-
- [TestMethod]
- public virtual void TryRemove_ReturnsFalse_WhenNotExists()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
- var result = dataSource.TryRemove(9999, out var removedItem);
-
- Assert.IsFalse(result);
- Assert.IsNull(removedItem);
- }
- #endregion
- #region SaveChanges Tests
- [TestMethod]
- public virtual async Task SaveChanges_SavesTrackedItems()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
-
- dataSource.Add(new TestOrderItem { Id = 101, ProductName = "Item 1" });
- dataSource.Add(new TestOrderItem { Id = 102, ProductName = "Item 2" });
-
- var unsaved = await dataSource.SaveChanges();
-
- Assert.AreEqual(0, unsaved.Count);
- Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
- }
-
- [TestMethod]
- public virtual async Task SaveChangesAsync_ClearsTracking()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
- dataSource.Add(new TestOrderItem { Id = 103, ProductName = "Item 3" });
-
- await dataSource.SaveChangesAsync();
-
- Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
- }
-
- [TestMethod]
- public virtual async Task SaveItem_ById_SavesSpecificItem()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
-
- dataSource.Add(new TestOrderItem { Id = 201, ProductName = "Specific" });
- var result = await dataSource.SaveItem(201);
-
- Assert.AreEqual("Specific", result.ProductName);
- Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
- }
-
- [TestMethod]
- public virtual async Task SaveItem_WithTrackingState_SavesCorrectly()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
- await dataSource.LoadDataSource();
-
- var item = dataSource[0];
- var result = await dataSource.SaveItem(item, TrackingState.Update);
-
- Assert.IsNotNull(result);
- }
- #endregion
- #region Tracking Tests
- [TestMethod]
- public virtual void SetTrackingStateToUpdate_MarksItemForUpdate()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
-
- dataSource.Add(new TestOrderItem { Id = 1, ProductName = "Test" });
- //await dataSource.SaveChanges(); // Elõbb mentsük el, hogy ne Add legyen a tracking state
-
- dataSource.SetTrackingStateToUpdate(dataSource[0]);
-
- Assert.AreEqual(1, dataSource.GetTrackingItems().Count);
- Assert.AreEqual(TrackingState.Update, dataSource.GetTrackingItems()[0].TrackingState);
- }
-
- [TestMethod]
- public virtual void SetTrackingStateToUpdate_DoesNotChangeAddState()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
-
- dataSource.Add(new TestOrderItem { Id = 1, ProductName = "New Item" });
- dataSource.SetTrackingStateToUpdate(dataSource[0]);
-
- Assert.AreEqual(TrackingState.Add, dataSource.GetTrackingItems()[0].TrackingState);
- }
-
- [TestMethod]
- public virtual void TryGetTrackingItem_ReturnsTrue_WhenTracked()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
-
- dataSource.Add(new TestOrderItem { Id = 1, ProductName = "Tracked" });
- var result = dataSource.TryGetTrackingItem(1, out var trackingItem);
-
- Assert.IsTrue(result);
- Assert.IsNotNull(trackingItem);
- Assert.AreEqual(TrackingState.Add, trackingItem.TrackingState);
- }
-
- [TestMethod]
- public virtual void TryGetTrackingItem_ReturnsFalse_WhenNotTracked()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
- var result = dataSource.TryGetTrackingItem(9999, out var trackingItem);
-
- Assert.IsFalse(result);
- Assert.IsNull(trackingItem);
- }
- #endregion
- #region Rollback Tests
- [TestMethod]
- public virtual void TryRollbackItem_RevertsAddedItem()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
-
- dataSource.Add(new TestOrderItem { Id = 1, ProductName = "Added" });
- var result = dataSource.TryRollbackItem(1, out var originalValue);
-
- Assert.IsTrue(result);
- Assert.AreEqual(0, dataSource.Count);
- Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
- }
-
- [TestMethod]
- public virtual async Task TryRollbackItem_RevertsUpdatedItem()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
- await dataSource.LoadDataSource();
-
- var originalName = dataSource[0].ProductName;
- dataSource[0] = new TestOrderItem { Id = dataSource[0].Id, ProductName = "Changed" };
- var result = dataSource.TryRollbackItem(dataSource[0].Id, out var originalValue);
-
- Assert.IsTrue(result);
- Assert.IsNotNull(originalValue);
- }
-
- [TestMethod]
- public virtual void Rollback_RevertsAllChanges()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
-
- dataSource.Add(new TestOrderItem { Id = 1, ProductName = "Item1" });
- dataSource.Add(new TestOrderItem { Id = 2, ProductName = "Item2" });
- dataSource.Rollback();
-
- Assert.AreEqual(0, dataSource.Count);
- Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
- }
- #endregion
- #region Collection Operations Tests
- [TestMethod]
- public virtual async Task Count_ReturnsCorrectValue()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
- await dataSource.LoadDataSource();
-
- Assert.AreEqual(3, dataSource.Count);
- }
-
- [TestMethod]
- public virtual void Clear_RemovesAllItems()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
-
- dataSource.Add(new TestOrderItem { Id = 1, ProductName = "Item1" });
- dataSource.Add(new TestOrderItem { Id = 2, ProductName = "Item2" });
- dataSource.Clear();
-
- Assert.AreEqual(0, dataSource.Count);
- Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
- }
-
- [TestMethod]
- public virtual void Clear_WithoutClearingTracking_PreservesTracking()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
-
- dataSource.Add(new TestOrderItem { Id = 1, ProductName = "Item1" });
- dataSource.Clear(clearChangeTracking: false);
-
- Assert.AreEqual(0, dataSource.Count);
- Assert.AreEqual(1, dataSource.GetTrackingItems().Count);
- }
-
- [TestMethod]
- public virtual async Task Contains_ReturnsTrue_WhenItemExists()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
- await dataSource.LoadDataSource();
-
- Assert.IsTrue(dataSource.Contains(dataSource[0]));
- }
-
- [TestMethod]
- public virtual void Contains_ReturnsFalse_WhenItemNotExists()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
- Assert.IsFalse(dataSource.Contains(new TestOrderItem { Id = 9999 }));
- }
-
- [TestMethod]
- public virtual async Task IndexOf_ReturnsCorrectIndex()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
- await dataSource.LoadDataSource();
-
- Assert.AreEqual(0, dataSource.IndexOf(dataSource[0]));
- Assert.AreEqual(1, dataSource.IndexOf(dataSource[1]));
- Assert.AreEqual(2, dataSource.IndexOf(dataSource[2]));
- }
-
- [TestMethod]
- public virtual void IndexOf_ById_ReturnsCorrectIndex()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
-
- dataSource.Add(new TestOrderItem { Id = 5, ProductName = "Item5" });
- Assert.AreEqual(0, dataSource.IndexOf(5));
- }
-
- [TestMethod]
- public virtual void TryGetIndex_ReturnsTrue_WhenExists()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
-
- dataSource.Add(new TestOrderItem { Id = 10, ProductName = "Test" });
- var result = dataSource.TryGetIndex(10, out var index);
-
- Assert.IsTrue(result);
- Assert.AreEqual(0, index);
- }
-
- [TestMethod]
- public virtual async Task TryGetValue_ReturnsItem_WhenExists()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
-
- await dataSource.LoadDataSource();
- var result = dataSource.TryGetValue(1, out var item);
-
- Assert.IsTrue(result);
- Assert.IsNotNull(item);
- Assert.AreEqual("Product A", item.ProductName);
- }
-
- [TestMethod]
- public virtual void TryGetValue_ReturnsFalse_WhenNotExists()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
- var result = dataSource.TryGetValue(9999, out var item);
-
- Assert.IsFalse(result);
- Assert.IsNull(item);
- }
-
- [TestMethod]
- public virtual async Task CopyTo_CopiesAllItems()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
-
- await dataSource.LoadDataSource();
- var array = new TestOrderItem[3];
- dataSource.CopyTo(array);
-
- Assert.AreEqual("Product A", array[0].ProductName);
- Assert.AreEqual("Product B", array[1].ProductName);
- Assert.AreEqual("Product C", array[2].ProductName);
- }
-
- [TestMethod]
- public virtual async Task GetEnumerator_EnumeratesAllItems()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
- await dataSource.LoadDataSource();
-
- var count = dataSource.Count();
-
- Assert.AreEqual(3, count);
- }
-
- [TestMethod]
- public virtual async Task AsReadOnly_ReturnsReadOnlyCollection()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
-
- await dataSource.LoadDataSource();
- var readOnly = dataSource.AsReadOnly();
-
- Assert.AreEqual(3, readOnly.Count);
- Assert.IsInstanceOfType(readOnly, typeof(System.Collections.ObjectModel.ReadOnlyCollection));
- }
- #endregion
- #region Working Reference List Tests
- [TestMethod]
- public virtual async Task SetWorkingReferenceList_SetsNewInnerList()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
- await dataSource.LoadDataSource();
-
- var externalList = Activator.CreateInstance();
- dataSource.SetWorkingReferenceList(externalList);
-
- Assert.IsTrue(dataSource.HasWorkingReferenceList);
- Assert.AreEqual(3, dataSource.Count);
- }
-
- [TestMethod]
- public virtual async Task GetReferenceInnerList_ReturnsInnerList()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
-
- await dataSource.LoadDataSource();
- var innerList = dataSource.GetReferenceInnerList();
-
- Assert.IsNotNull(innerList);
- Assert.AreEqual(3, innerList.Count);
- }
- #endregion
- #region Sync State Tests
- [TestMethod]
- public virtual void IsSyncing_IsFalse_Initially()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
- Assert.IsFalse(dataSource.IsSyncing);
- }
-
- [TestMethod]
- public virtual async Task OnSyncingStateChanged_Fires_DuringLoad()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
- var syncStarted = false;
- var syncEnded = false;
-
- dataSource.OnSyncingStateChanged += isSyncing =>
- {
- if (isSyncing) syncStarted = true;
- else syncEnded = true;
- };
-
- await dataSource.LoadDataSource();
-
- Assert.IsTrue(syncStarted);
- Assert.IsTrue(syncEnded);
- }
- #endregion
- #region Context and Filter Tests
- [TestMethod]
- public virtual void ContextIds_CanBeSetAndRetrieved()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
-
- var contextIds = new object[] { 1, "test", Guid.NewGuid() };
- dataSource.ContextIds = contextIds;
-
- Assert.AreEqual(3, dataSource.ContextIds?.Length);
- }
-
- [TestMethod]
- public virtual void FilterText_CanBeSetAndRetrieved()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
- dataSource.FilterText = "search term";
-
- Assert.AreEqual("search term", dataSource.FilterText);
- }
- #endregion
- #region IList Interface Tests
- [TestMethod]
- public virtual void IList_Add_ReturnsCorrectIndex()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
- var item = new TestOrderItem { Id = 1, ProductName = "Test" };
- var index = ((IList)dataSource).Add(item);
-
- Assert.AreEqual(0, index);
- }
-
- [TestMethod]
- public virtual void IList_Contains_WorksCorrectly()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
-
- var item = new TestOrderItem { Id = 1, ProductName = "Test" };
- dataSource.Add(item);
-
- Assert.IsTrue(((IList)dataSource).Contains(item));
- }
-
- [TestMethod]
- public virtual void IList_IndexOf_WorksCorrectly()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
-
- var item = new TestOrderItem { Id = 1, ProductName = "Test" };
- dataSource.Add(item);
-
- Assert.AreEqual(0, ((IList)dataSource).IndexOf(item));
- }
-
- [TestMethod]
- public virtual void IList_Insert_WorksCorrectly()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
-
- dataSource.Add(new TestOrderItem { Id = 1, ProductName = "First" });
- var newItem = new TestOrderItem { Id = 2, ProductName = "Inserted" };
-
- ((IList)dataSource).Insert(0, newItem);
-
- Assert.AreEqual("Inserted", dataSource[0].ProductName);
- }
-
- [TestMethod]
- public virtual void IList_Remove_WorksCorrectly()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
-
- var item = new TestOrderItem { Id = 1, ProductName = "Test" };
- dataSource.Add(item);
-
- ((IList)dataSource).Remove(item);
- Assert.AreEqual(0, dataSource.Count);
- }
-
- [TestMethod]
- public virtual void IList_Indexer_GetSet_WorksCorrectly()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
-
- dataSource.Add(new TestOrderItem { Id = 1, ProductName = "Original" });
- ((IList)dataSource)[0] = new TestOrderItem { Id = 1, ProductName = "Modified" };
-
- Assert.AreEqual("Modified", dataSource[0].ProductName);
- }
-
- [TestMethod]
- public virtual void ICollection_CopyTo_WorksCorrectly()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
-
- dataSource.Add(new TestOrderItem { Id = 1, ProductName = "Item1" });
- dataSource.Add(new TestOrderItem { Id = 2, ProductName = "Item2" });
-
- var array = new TestOrderItem[2];
- ((ICollection)dataSource).CopyTo(array, 0);
-
- Assert.AreEqual("Item1", array[0].ProductName);
- Assert.AreEqual("Item2", array[1].ProductName);
- }
-
- [TestMethod]
- public virtual void IsSynchronized_ReturnsTrue()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
- Assert.IsTrue(dataSource.IsSynchronized);
- }
-
- [TestMethod]
- public virtual void SyncRoot_IsNotNull()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
- Assert.IsNotNull(dataSource.SyncRoot);
- }
-
- [TestMethod]
- public virtual void IsFixedSize_ReturnsFalse()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
- Assert.IsFalse(dataSource.IsFixedSize);
- }
-
- [TestMethod]
- public virtual void IsReadOnly_ReturnsFalse()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
- Assert.IsFalse(((IList)dataSource).IsReadOnly);
- }
- #endregion
- #region Edge Cases
- [TestMethod]
- public virtual async Task Indexer_OutOfRange_ThrowsException()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
- await dataSource.LoadDataSource();
-
- Assert.ThrowsExactly(() => _ = dataSource[999]);
- }
-
- [TestMethod]
- public virtual void Add_ThenRemove_ClearsTracking()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
- var item = new TestOrderItem { Id = 1, ProductName = "Temporary" };
-
- dataSource.Add(item);
- Assert.AreEqual(1, dataSource.GetTrackingItems().Count);
-
- dataSource.Remove(item);
- Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
- }
-
- [TestMethod]
- public virtual async Task ComplexWorkflow_AddUpdateRemoveSave()
- {
- var dataSource = CreateDataSource(_client, _crudTags);
-
- // Add items
- dataSource.Add(new TestOrderItem { Id = 1, ProductName = "Item1", Quantity = 10 });
- dataSource.Add(new TestOrderItem { Id = 2, ProductName = "Item2", Quantity = 20 });
- Assert.AreEqual(2, dataSource.GetTrackingItems().Count);
-
- // Save
- await dataSource.SaveChanges();
- Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
-
- // Update
- dataSource[0] = new TestOrderItem { Id = 1, ProductName = "Updated1", Quantity = 100 };
- Assert.AreEqual(1, dataSource.GetTrackingItems().Count);
- Assert.AreEqual(TrackingState.Update, dataSource.GetTrackingItems()[0].TrackingState);
-
- // Remove
- dataSource.Remove(dataSource[1]);
- Assert.AreEqual(2, dataSource.GetTrackingItems().Count);
-
- // Save all changes
- await dataSource.SaveChanges();
- Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
- Assert.AreEqual(1, dataSource.Count);
- }
- #endregion
-}
-#endregion
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/AyCode.Services.Server.Tests/SignalRs/SignalRDatasources/SignalRDataSourceTests_List_Json.cs b/AyCode.Services.Server.Tests/SignalRs/SignalRDatasources/SignalRDataSourceTests_List_Json.cs
index fbc49a0..b61f811 100644
--- a/AyCode.Services.Server.Tests/SignalRs/SignalRDatasources/SignalRDataSourceTests_List_Json.cs
+++ b/AyCode.Services.Server.Tests/SignalRs/SignalRDatasources/SignalRDataSourceTests_List_Json.cs
@@ -160,4 +160,16 @@ public class SignalRDataSourceTests_List_Json : SignalRDataSourceTestBase base.Add_ThenRemove_ClearsTracking();
[TestMethod]
public override async Task ComplexWorkflow_AddUpdateRemoveSave() => await base.ComplexWorkflow_AddUpdateRemoveSave();
+
+ // Expression / AcLinq Filter Tests
+ [TestMethod]
+ public override async Task ExpressionFilter_WithQuantityGreaterThan_ReturnsFilteredItems() => await base.ExpressionFilter_WithQuantityGreaterThan_ReturnsFilteredItems();
+ [TestMethod]
+ public override async Task ExpressionFilter_WithUnitPriceGreaterThan_ReturnsFilteredItems() => await base.ExpressionFilter_WithUnitPriceGreaterThan_ReturnsFilteredItems();
+ [TestMethod]
+ public override async Task ExpressionFilter_WithNoFilter_ReturnsAllItems() => await base.ExpressionFilter_WithNoFilter_ReturnsAllItems();
+ [TestMethod]
+ public override async Task ExpressionFilter_WithProductNameContains_ReturnsFilteredItems() => await base.ExpressionFilter_WithProductNameContains_ReturnsFilteredItems();
+ [TestMethod]
+ public override async Task ExpressionFilter_WithComplexCondition_ReturnsFilteredItems() => await base.ExpressionFilter_WithComplexCondition_ReturnsFilteredItems();
}
\ No newline at end of file
diff --git a/AyCode.Services.Server.Tests/SignalRs/TestSignalRService2.cs b/AyCode.Services.Server.Tests/SignalRs/TestSignalRService2.cs
index a3b66f8..2137bd2 100644
--- a/AyCode.Services.Server.Tests/SignalRs/TestSignalRService2.cs
+++ b/AyCode.Services.Server.Tests/SignalRs/TestSignalRService2.cs
@@ -1,7 +1,14 @@
+using System;
+using System.Collections.Generic;
using System.Globalization;
+using System.Linq;
+using System.Linq.Expressions;
+using System.Threading.Tasks;
+using AyCode.Core.Serializers;
+using AyCode.Core.Serializers.Expressions;
using AyCode.Core.Tests.TestModels;
using AyCode.Services.SignalRs;
-using static AyCode.Core.Tests.Serialization.AcSerializerModels;
+using static AyCode.Core.Tests.TestModels.AcSerializerModels;
using AyCode.Core.Tests.Serialization;
namespace AyCode.Services.Server.Tests.SignalRs;
@@ -443,6 +450,69 @@ public class TestSignalRService2
[SignalR(TestSignalRTags.DataSourceGetAll)]
public List DataSourceGetAll() => _dataSourceItems.ToList();
+ ///
+ /// DataSource GetAll with Expression filter.
+ /// The deserializer automatically converts AcExpressionNode to Expression.
+ /// Uses IQueryable.Where() instead of Compile() for EF Core compatibility.
+ ///
+ [SignalR(TestSignalRTags.ExpressionDataSourceGetAll)]
+ public List ExpressionDataSourceGetAll(Expression>? filter = null)
+ {
+ if (filter == null)
+ return _dataSourceItems.ToList();
+
+ // Use AsQueryable().Where() instead of Compile() - this is how EF Core works
+ return _dataSourceItems.AsQueryable().Where(filter).ToList();
+ }
+
+ ///
+ /// DataSource GetAll with AcExpressionNode query parameter.
+ /// Demonstrates EF Core-like usage where the client sends a query expression.
+ /// The expression is rebuilt and applied to the server's IQueryable.
+ ///
+ [SignalR(TestSignalRTags.QueryableDataSourceGetAll)]
+ public List QueryableDataSourceGetAll(AcExpressionNode? queryNode = null)
+ {
+ // Simulate DbContext.Items (IQueryable)
+ IQueryable serverQuery = _dataSourceItems.AsQueryable();
+
+ // If client sent a query node, rebuild and apply it
+ if (queryNode != null)
+ {
+ // If it's a lambda expression, use it directly as a filter
+ if (queryNode.NodeType == ExpressionType.Lambda)
+ {
+ var filter = AcExpressionRebuilder.FromNode(queryNode);
+ serverQuery = serverQuery.Where(filter);
+ }
+ else
+ {
+ // For more complex queries (Where chains, OrderBy, etc.)
+ serverQuery = AcSerializerCommon.ApplyQueryFromNode(serverQuery, queryNode);
+ }
+ }
+
+ // Materialize results - like .ToListAsync() in EF Core
+ return serverQuery.ToList();
+ }
+
+ ///
+ /// Executes an aggregate query (Count, Sum, Average, etc.) on the server.
+ /// Returns a scalar value instead of a collection.
+ /// This is the proper way to execute Count() - on the server, not client!
+ ///
+ [SignalR(TestSignalRTags.QueryableDataSourceAggregate)]
+ public object? QueryableDataSourceAggregate(AcExpressionNode queryNode)
+ {
+ // Build the query from the expression node
+ IQueryable serverQuery = _dataSourceItems.AsQueryable();
+
+ // Apply the full expression tree (including the aggregate method)
+ var result = AcSerializerCommon.ExecuteQueryFromNode(serverQuery, queryNode);
+
+ return result;
+ }
+
[SignalR(TestSignalRTags.DataSourceGetItem)]
public TestOrderItem? DataSourceGetItem(int id) => _dataSourceItems.FirstOrDefault(x => x.Id == id);
diff --git a/AyCode.Services.Server.Tests/SignalRs/TestSignalRTags.cs b/AyCode.Services.Server.Tests/SignalRs/TestSignalRTags.cs
index 3a33f96..011be28 100644
--- a/AyCode.Services.Server.Tests/SignalRs/TestSignalRTags.cs
+++ b/AyCode.Services.Server.Tests/SignalRs/TestSignalRTags.cs
@@ -94,4 +94,13 @@ public abstract class TestSignalRTags : AcSignalRTags
// Default parameter tests
public const int TwoParamsWithDefault = 410;
+
+ // Expression filter DataSource GetAll
+ public const int ExpressionDataSourceGetAll = 500;
+
+ // IQueryable filter DataSource GetAll (demonstrates EF Core-like usage)
+ public const int QueryableDataSourceGetAll = 501;
+
+ // IQueryable aggregate query (Count, Sum, etc.) - returns scalar value
+ public const int QueryableDataSourceAggregate = 502;
}
diff --git a/AyCode.Services.Server/SignalRs/AcWebSignalRHubBase.cs b/AyCode.Services.Server/SignalRs/AcWebSignalRHubBase.cs
index ddd4945..8a711e3 100644
--- a/AyCode.Services.Server/SignalRs/AcWebSignalRHubBase.cs
+++ b/AyCode.Services.Server/SignalRs/AcWebSignalRHubBase.cs
@@ -327,12 +327,30 @@ public abstract class AcWebSignalRHubBase(IConfiguration
if (paramInfos is not { Length: > 0 })
return null;
+ // Handle case where message is null/empty but all parameters have default values
if (message is null or { Length: 0 })
+ {
+ // Check if all parameters have default values
+ if (paramInfos.All(p => p.HasDefaultValue))
+ {
+ // Return default values for all parameters
+ return paramInfos.Select(p => p.DefaultValue).ToArray();
+ }
+
throw new ArgumentException($"Message is null or empty but method '{methodName}' requires parameters; {tagName}");
+ }
var msgBase = SignalRSerializationHelper.DeserializeFromBinary(message);
if (string.IsNullOrEmpty(msgBase?.PostDataJson))
+ {
+ // Check if all parameters have default values
+ if (paramInfos.All(p => p.HasDefaultValue))
+ {
+ return paramInfos.Select(p => p.DefaultValue).ToArray();
+ }
+
throw new ArgumentException($"Failed to deserialize message for method '{methodName}'; {tagName}");
+ }
var json = msgBase.PostDataJson;
var paramValues = new object?[paramInfos.Length];
diff --git a/TestChainApi.cs b/TestChainApi.cs
new file mode 100644
index 0000000..430449f
--- /dev/null
+++ b/TestChainApi.cs
@@ -0,0 +1,48 @@
+using AyCode.Core.Extensions;
+using AyCode.Core.Serializers.Jsons;
+
+namespace AyCode.Core.Tests;
+
+public class TestChainApi
+{
+ public static void RunTest()
+ {
+ var json = """{"Name":"John","Age":30,"City":"New York"}""";
+
+ // Test 1: DeserializeChain
+ Console.WriteLine("=== Test DeserializeChain ===");
+ using (var chain = json.JsonToChain())
+ {
+ var person = chain.Value;
+ Console.WriteLine($"Person: {person?.Name}, {person?.Age}");
+
+ var dict = chain.ThenDeserialize>();
+ Console.WriteLine($"Dict: {dict?.Count} keys");
+
+ var person2 = chain.ThenDeserialize();
+ Console.WriteLine($"Person2: {person2?.Name}, {person2?.Age}");
+ }
+
+ // Test 2: PopulateChain
+ Console.WriteLine("\n=== Test PopulateChain ===");
+ var target1 = new Person { Name = "Old", Age = 99, City = "Old City" };
+ var target2 = new Person { Name = "Old2", Age = 88, City = "Old City 2" };
+
+ using (var chain = json.JsonToChain(target1))
+ {
+ Console.WriteLine($"Target1 after populate: {target1.Name}, {target1.Age}, {target1.City}");
+
+ chain.ThenPopulate(target2);
+ Console.WriteLine($"Target2 after populate: {target2.Name}, {target2.Age}, {target2.City}");
+ }
+
+ Console.WriteLine("\n=== All tests passed! ===");
+ }
+}
+
+public class Person
+{
+ public string Name { get; set; } = "";
+ public int Age { get; set; }
+ public string City { get; set; } = "";
+}
diff --git a/TestChainRunner/TestChainRunner.csproj b/TestChainRunner/TestChainRunner.csproj
new file mode 100644
index 0000000..631ceeb
--- /dev/null
+++ b/TestChainRunner/TestChainRunner.csproj
@@ -0,0 +1,13 @@
+
+
+
+ Exe
+ net9.0
+ enable
+
+
+
+
+
+
+