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 CreateCompiledGetter(Type declaringType, PropertyInfo prop) + { + var objParam = LExpression.Parameter(typeof(object), "obj"); + var castExpr = LExpression.Convert(objParam, declaringType); + var propAccess = LExpression.Property(castExpr, prop); + var boxed = LExpression.Convert(propAccess, typeof(object)); + return LExpression.Lambda>(boxed, objParam).Compile(); + } + + /// + /// Creates a compiled setter for a property using expression trees. + /// Handles nullable value types correctly. + /// + public static Action CreateCompiledSetter(Type declaringType, PropertyInfo prop) + { + var targetParam = LExpression.Parameter(typeof(object), "target"); + var valueParam = LExpression.Parameter(typeof(object), "value"); + + var castTarget = LExpression.Convert(targetParam, declaringType); + var propertyAccess = LExpression.Property(castTarget, prop); + + Expression castValue; + var propertyType = prop.PropertyType; + var underlyingType = Nullable.GetUnderlyingType(propertyType); + + if (underlyingType != null) + { + // Nullable value type: unbox to underlying type, then convert to nullable + var unboxed = LExpression.Unbox(valueParam, underlyingType); + castValue = LExpression.Convert(unboxed, propertyType); + } + else if (propertyType.IsValueType) + { + // Non-nullable value type: use Unbox for proper unboxing + castValue = LExpression.Unbox(valueParam, propertyType); + } + else + { + // Reference type: use TypeAs for safe casting + castValue = LExpression.TypeAs(valueParam, propertyType); + } + + var assign = LExpression.Assign(propertyAccess, castValue); + return LExpression.Lambda>(assign, targetParam, valueParam).Compile(); + } + + /// + /// Creates a compiled parameterless constructor for a type. + /// Returns null if type is abstract or has no parameterless constructor. + /// + public static Func? CreateCompiledConstructor(Type type) + { + if (type.IsAbstract) return null; + + var ctor = type.GetConstructor(BindingFlags.Instance | BindingFlags.Public, null, Type.EmptyTypes, null); + if (ctor == null) return null; + + var newExpr = LExpression.New(ctor); + var convert = LExpression.Convert(newExpr, typeof(object)); + return LExpression.Lambda>(convert).Compile(); + } + + /// + /// Creates a typed getter delegate to avoid boxing for value types. + /// + public static Func CreateTypedGetter(Type declaringType, PropertyInfo prop) + { + var objParam = LExpression.Parameter(typeof(object), "obj"); + var castExpr = LExpression.Convert(objParam, declaringType); + var propAccess = LExpression.Property(castExpr, prop); + var convertExpr = LExpression.Convert(propAccess, typeof(TProperty)); + return LExpression.Lambda>(convertExpr, objParam).Compile(); + } + + /// + /// Creates an enum getter that returns int to avoid boxing. + /// + public static Func CreateEnumGetter(Type declaringType, PropertyInfo prop) + { + var objParam = LExpression.Parameter(typeof(object), "obj"); + var castExpr = LExpression.Convert(objParam, declaringType); + var propAccess = LExpression.Property(castExpr, prop); + var convertToInt = LExpression.Convert(propAccess, typeof(int)); + return LExpression.Lambda>(convertToInt, objParam).Compile(); + } + + #endregion +} \ No newline at end of file diff --git a/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.BinaryDeserializeTypeMetadata.cs b/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.BinaryDeserializeTypeMetadata.cs index 3c1bba2..c9a3cbd 100644 --- a/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.BinaryDeserializeTypeMetadata.cs +++ b/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.BinaryDeserializeTypeMetadata.cs @@ -32,24 +32,12 @@ public static partial class AcBinaryDeserializer ? FrozenDictionary.Empty : PropertiesArray.ToFrozenDictionary(static p => p.Name, static p => p, StringComparer.Ordinal); - CompiledConstructor = TryCreateConstructor(type); + CompiledConstructor = AcSerializerCommon.CreateCompiledConstructor(type); } [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool TryGetProperty(string name, out BinaryPropertySetterInfo? propertyInfo) => _properties.TryGetValue(name, out propertyInfo); - - private static Func? TryCreateConstructor(Type type) - { - if (type.IsAbstract) return null; - - var ctor = type.GetConstructor(BindingFlags.Instance | BindingFlags.Public, null, Type.EmptyTypes, null); - if (ctor == null) return null; - - var newExpr = Expression.New(ctor); - var convert = Expression.Convert(newExpr, typeof(object)); - return Expression.Lambda>(convert).Compile(); - } } internal sealed class BinaryPropertySetterInfo @@ -73,7 +61,7 @@ public static partial class AcBinaryDeserializer { Name = property.Name; PropertyType = property.PropertyType; - IsCollection = IsCollectionType(PropertyType); + IsCollection = IsCollectionTypeCheck(PropertyType); ElementType = IsCollection ? GetCollectionElementType(PropertyType) : null; if (ElementType != null) @@ -87,14 +75,14 @@ public static partial class AcBinaryDeserializer var idProp = ElementType.GetProperty("Id", BindingFlags.Instance | BindingFlags.Public); if (idProp != null) { - ElementIdGetter = CreateCompiledGetter(ElementType, idProp); + ElementIdGetter = AcSerializerCommon.CreateCompiledGetter(ElementType, idProp); } } } IsComplexType = IsComplex(PropertyType); - _getter = CreateGetter(property); - _setter = CreateSetter(property); + _getter = AcSerializerCommon.CreateCompiledGetter(property.DeclaringType!, property); + _setter = AcSerializerCommon.CreateCompiledSetter(property.DeclaringType!, property); } public BinaryPropertySetterInfo( @@ -157,7 +145,7 @@ public static partial class AcBinaryDeserializer } } - private static bool IsCollectionType(Type type) + private static bool IsCollectionTypeCheck(Type type) { if (ReferenceEquals(type, StringType)) return false; if (type.IsArray) return true; @@ -167,50 +155,15 @@ public static partial class AcBinaryDeserializer private static bool IsComplex(Type type) { var actualType = Nullable.GetUnderlyingType(type) ?? type; - return IsComplexType(actualType); - } - - private static Func CreateGetter(PropertyInfo property) - { - var targetParam = Expression.Parameter(typeof(object), "target"); - var castTarget = Expression.Convert(targetParam, property.DeclaringType!); - var propertyAccess = Expression.Property(castTarget, property); - var boxed = Expression.Convert(propertyAccess, typeof(object)); - return Expression.Lambda>(boxed, targetParam).Compile(); - } - - private static Action CreateSetter(PropertyInfo property) - { - var targetParam = Expression.Parameter(typeof(object), "target"); - var valueParam = Expression.Parameter(typeof(object), "value"); - - var castTarget = Expression.Convert(targetParam, property.DeclaringType!); - var propertyAccess = Expression.Property(castTarget, property); - - Expression castValue; - var propertyType = property.PropertyType; - var underlyingType = Nullable.GetUnderlyingType(propertyType); - - if (underlyingType != null) - { - // Nullable value type: first unbox to underlying type, then convert to nullable - // This handles cases where we receive int but need to set int? - var unboxed = Expression.Unbox(valueParam, underlyingType); - castValue = Expression.Convert(unboxed, propertyType); - } - else if (propertyType.IsValueType) - { - // Non-nullable value type: use Unbox for proper unboxing - castValue = Expression.Unbox(valueParam, propertyType); - } - else - { - // Reference type: use TypeAs for safe casting (returns null if incompatible) - castValue = Expression.TypeAs(valueParam, propertyType); - } - - var assign = Expression.Assign(propertyAccess, castValue); - return Expression.Lambda>(assign, targetParam, valueParam).Compile(); + if (actualType.IsPrimitive) return false; + if (ReferenceEquals(actualType, StringType)) return false; + if (actualType.IsEnum) return false; + if (ReferenceEquals(actualType, GuidType)) return false; + if (ReferenceEquals(actualType, DateTimeType)) return false; + if (ReferenceEquals(actualType, DecimalType)) return false; + if (ReferenceEquals(actualType, TimeSpanType)) return false; + if (ReferenceEquals(actualType, DateTimeOffsetType)) return false; + return true; } } } diff --git a/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.cs b/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.cs index 21bc8bc..5d769e0 100644 --- a/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.cs +++ b/AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.cs @@ -7,6 +7,7 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Text; using AyCode.Core.Helpers; +using AyCode.Core.Serializers.Expressions; using static AyCode.Core.Helpers.JsonUtilities; namespace AyCode.Core.Serializers.Binaries; @@ -132,6 +133,13 @@ public static partial class AcBinaryDeserializer if (data.Length == 1 && data[0] == BinaryTypeCode.Null) return default; var targetType = typeof(T); + + // Handle Expression types - deserialize as AcExpressionNode and rebuild + if (AcSerializerCommon.IsExpressionType(targetType)) + { + return (T?)(object?)DeserializeExpression(data, targetType, options); + } + var context = new BinaryDeserializationContext(data, options); try @@ -166,6 +174,12 @@ public static partial class AcBinaryDeserializer if (data.Length == 0) return null; if (data.Length == 1 && data[0] == BinaryTypeCode.Null) return null; + // Handle Expression types - deserialize as AcExpressionNode and rebuild + if (AcSerializerCommon.IsExpressionType(targetType)) + { + return DeserializeExpression(data, targetType, options); + } + var context = new BinaryDeserializationContext(data, options); try @@ -185,6 +199,35 @@ public static partial class AcBinaryDeserializer } } + /// + /// Deserialize Expression from binary data. + /// First deserializes as AcExpressionNode, then rebuilds the Expression tree. + /// + private static Expression? DeserializeExpression(ReadOnlySpan data, Type targetExpressionType, AcBinarySerializerOptions options) + { + var context = new BinaryDeserializationContext(data, options); + + try + { + context.ReadHeader(); + var node = (AcExpressionNode?)ReadValue(ref context, typeof(AcExpressionNode), 0); + if (node == null) return null; + + var entityType = AcSerializerCommon.GetExpressionEntityType(targetExpressionType); + return AcExpressionRebuilder.FromNode(node, entityType); + } + catch (AcBinaryDeserializationException) + { + throw; + } + catch (Exception ex) + { + throw new AcBinaryDeserializationException( + $"Failed to deserialize Expression from binary data: {ex.Message}", + context.Position, targetExpressionType, ex); + } + } + /// /// Populate existing object from binary data. /// diff --git a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.BinaryTypeMetadata.cs b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.BinaryTypeMetadata.cs index 976eb1b..4f1828d 100644 --- a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.BinaryTypeMetadata.cs +++ b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.BinaryTypeMetadata.cs @@ -55,8 +55,8 @@ public static partial class AcBinarySerializer PropertyType = Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType; TypeCode = Type.GetTypeCode(PropertyType); - (_typedGetter, _accessorType) = CreateTypedGetter(DeclaringType, prop); - _objectGetter = CreateObjectGetter(DeclaringType, prop); + (_typedGetter, _accessorType) = CreateTypedGetterForAccessor(DeclaringType, prop); + _objectGetter = AcSerializerCommon.CreateCompiledGetter(DeclaringType, prop); } public PropertyAccessorType AccessorType => _accessorType; @@ -107,7 +107,7 @@ public static partial class AcBinarySerializer [MethodImpl(MethodImplOptions.AggressiveInlining)] public int GetEnumAsInt32(object obj) => ((Func)_typedGetter!)(obj); - private static (Delegate?, PropertyAccessorType) CreateTypedGetter(Type declaringType, PropertyInfo prop) + private static (Delegate?, PropertyAccessorType) CreateTypedGetterForAccessor(Type declaringType, PropertyInfo prop) { var propType = prop.PropertyType; var underlying = Nullable.GetUnderlyingType(propType); @@ -118,59 +118,32 @@ public static partial class AcBinarySerializer if (propType.IsEnum) { - return (CreateEnumGetter(declaringType, prop), PropertyAccessorType.Enum); + return (AcSerializerCommon.CreateEnumGetter(declaringType, prop), PropertyAccessorType.Enum); } if (ReferenceEquals(propType, GuidType)) { - return (CreateTypedGetterDelegate(declaringType, prop), PropertyAccessorType.Guid); + return (AcSerializerCommon.CreateTypedGetter(declaringType, prop), PropertyAccessorType.Guid); } var typeCode = Type.GetTypeCode(propType); return typeCode switch { - TypeCode.Int32 => (CreateTypedGetterDelegate(declaringType, prop), PropertyAccessorType.Int32), - TypeCode.Int64 => (CreateTypedGetterDelegate(declaringType, prop), PropertyAccessorType.Int64), - TypeCode.Boolean => (CreateTypedGetterDelegate(declaringType, prop), PropertyAccessorType.Boolean), - TypeCode.Double => (CreateTypedGetterDelegate(declaringType, prop), PropertyAccessorType.Double), - TypeCode.Single => (CreateTypedGetterDelegate(declaringType, prop), PropertyAccessorType.Single), - TypeCode.Decimal => (CreateTypedGetterDelegate(declaringType, prop), PropertyAccessorType.Decimal), - TypeCode.DateTime => (CreateTypedGetterDelegate(declaringType, prop), PropertyAccessorType.DateTime), - TypeCode.Byte => (CreateTypedGetterDelegate(declaringType, prop), PropertyAccessorType.Byte), - TypeCode.Int16 => (CreateTypedGetterDelegate(declaringType, prop), PropertyAccessorType.Int16), - TypeCode.UInt16 => (CreateTypedGetterDelegate(declaringType, prop), PropertyAccessorType.UInt16), - TypeCode.UInt32 => (CreateTypedGetterDelegate(declaringType, prop), PropertyAccessorType.UInt32), - TypeCode.UInt64 => (CreateTypedGetterDelegate(declaringType, prop), PropertyAccessorType.UInt64), + TypeCode.Int32 => (AcSerializerCommon.CreateTypedGetter(declaringType, prop), PropertyAccessorType.Int32), + TypeCode.Int64 => (AcSerializerCommon.CreateTypedGetter(declaringType, prop), PropertyAccessorType.Int64), + TypeCode.Boolean => (AcSerializerCommon.CreateTypedGetter(declaringType, prop), PropertyAccessorType.Boolean), + TypeCode.Double => (AcSerializerCommon.CreateTypedGetter(declaringType, prop), PropertyAccessorType.Double), + TypeCode.Single => (AcSerializerCommon.CreateTypedGetter(declaringType, prop), PropertyAccessorType.Single), + TypeCode.Decimal => (AcSerializerCommon.CreateTypedGetter(declaringType, prop), PropertyAccessorType.Decimal), + TypeCode.DateTime => (AcSerializerCommon.CreateTypedGetter(declaringType, prop), PropertyAccessorType.DateTime), + TypeCode.Byte => (AcSerializerCommon.CreateTypedGetter(declaringType, prop), PropertyAccessorType.Byte), + TypeCode.Int16 => (AcSerializerCommon.CreateTypedGetter(declaringType, prop), PropertyAccessorType.Int16), + TypeCode.UInt16 => (AcSerializerCommon.CreateTypedGetter(declaringType, prop), PropertyAccessorType.UInt16), + TypeCode.UInt32 => (AcSerializerCommon.CreateTypedGetter(declaringType, prop), PropertyAccessorType.UInt32), + TypeCode.UInt64 => (AcSerializerCommon.CreateTypedGetter(declaringType, prop), PropertyAccessorType.UInt64), _ => (null, PropertyAccessorType.Object) }; } - - private static Delegate CreateEnumGetter(Type declaringType, PropertyInfo prop) - { - var objParam = Expression.Parameter(typeof(object), "obj"); - var castExpr = Expression.Convert(objParam, declaringType); - var propAccess = Expression.Property(castExpr, prop); - var convertToInt = Expression.Convert(propAccess, typeof(int)); - return Expression.Lambda>(convertToInt, objParam).Compile(); - } - - private static Func CreateTypedGetterDelegate(Type declaringType, PropertyInfo prop) - { - var objParam = Expression.Parameter(typeof(object), "obj"); - var castExpr = Expression.Convert(objParam, declaringType); - var propAccess = Expression.Property(castExpr, prop); - var convertExpr = Expression.Convert(propAccess, typeof(TProperty)); - return Expression.Lambda>(convertExpr, objParam).Compile(); - } - - private static Func CreateObjectGetter(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(); - } } internal enum PropertyAccessorType : byte diff --git a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs index f49863c..27d91cb 100644 --- a/AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs +++ b/AyCode.Core/Serializers/Binaries/AcBinarySerializer.cs @@ -6,6 +6,7 @@ using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Text; +using AyCode.Core.Serializers.Expressions; using static AyCode.Core.Helpers.JsonUtilities; using ReferenceEqualityComparer = AyCode.Core.Serializers.Jsons.ReferenceEqualityComparer; @@ -21,6 +22,7 @@ namespace AyCode.Core.Serializers.Binaries; /// - Optional metadata for schema evolution /// - Optimized buffer management with ArrayPool /// - Zero-allocation hot paths using Span and MemoryMarshal +/// - Automatic Expression to AcExpressionNode conversion /// 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 + + + + + + +