Add expression serialization & chain API for JSON deserialization
- Introduced a high-performance, reusable "chain" API for JSON deserialization and object population, enabling parsed JSON to be reused for multiple deserializations without reparsing. Exposed via new extension methods and interfaces (`IDeserializeChain<T>`, `IPopulateChain`). - Added comprehensive infrastructure for serializing and deserializing .NET Expression trees to a universal DTO (`AcExpressionNode`), with full round-trip support and robust handling of constants, closures, and queryable expressions. - Centralized all property accessor and expression utilities in `AcSerializerCommon` to avoid duplication and improve maintainability. - Enhanced both JSON and binary serializers to support Expression and IQueryable types, with automatic conversion to/from `AcExpressionNode`. - Refactored type metadata and property accessor logic for performance and code reuse. - Added extensive unit tests for the new chain API and expression serialization, and reorganized test models and namespaces for clarity. - Improved logging, error handling, and test infrastructure. - Misc: Added settings for local builds, updated project files, and cleaned up obsolete code.
This commit is contained in:
parent
9fad870960
commit
9f8c027366
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(dotnet build:*)",
|
||||
"Bash(dotnet test:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for AcExpressionNode serialization.
|
||||
/// These tests verify that the serializers properly handle expression nodes
|
||||
/// with the new type-safe constant value storage.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class AcExpressionNodeSerializationTests
|
||||
{
|
||||
#region JSON Serializer Tests
|
||||
|
||||
/// <summary>
|
||||
/// Tests that AcExpressionNode with type-safe properties can be JSON serialized.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void AcJsonSerializer_WithAcExpressionNode_RoundTrip_Works()
|
||||
{
|
||||
// Arrange - Create an expression with a constant value
|
||||
System.Linq.Expressions.Expression<Func<TestOrderItem, bool>> 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<AcExpressionNode>(json);
|
||||
Assert.IsNotNull(deserialized, "Deserialized node should not be null");
|
||||
|
||||
// Rebuild and test
|
||||
var rebuiltExpression = AcExpressionRebuilder.FromNode<TestOrderItem, bool>(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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that decimal constant values are preserved after JSON round-trip.
|
||||
/// </summary>
|
||||
[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<AcExpressionNode>(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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that Guid constant values are preserved after JSON round-trip.
|
||||
/// </summary>
|
||||
[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<AcExpressionNode>(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
|
||||
|
||||
/// <summary>
|
||||
/// Tests AcBinarySerializer with AcExpressionNode round-trip.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void AcBinarySerializer_WithAcExpressionNode_RoundTrip_Works()
|
||||
{
|
||||
// Arrange
|
||||
System.Linq.Expressions.Expression<Func<TestOrderItem, bool>> 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<AcExpressionNode>(bytes);
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(deserialized);
|
||||
Assert.AreEqual(originalNode.NodeType, deserialized.NodeType);
|
||||
|
||||
// Rebuild and test
|
||||
var rebuiltExpression = AcExpressionRebuilder.FromNode<TestOrderItem, bool>(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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests AcBinarySerializer with decimal in typed property.
|
||||
/// </summary>
|
||||
[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<AcExpressionNode>(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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests complex expression with decimal comparison.
|
||||
/// </summary>
|
||||
[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<Func<TestOrderItem, bool>> filterExpression =
|
||||
item => item.UnitPrice > minPrice;
|
||||
|
||||
var originalNode = AcExpressionConverter.ToNode(filterExpression);
|
||||
|
||||
// Act - Binary round-trip
|
||||
var bytes = AcBinarySerializer.Serialize(originalNode);
|
||||
var deserializedNode = AcBinaryDeserializer.Deserialize<AcExpressionNode>(bytes);
|
||||
|
||||
// Assert - Rebuild and verify it still works with decimal comparison
|
||||
Assert.IsNotNull(deserializedNode);
|
||||
|
||||
var rebuiltExpression = AcExpressionRebuilder.FromNode<TestOrderItem, bool>(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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests enum constant values.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void AcBinarySerializer_WithEnumValue_PreservesType()
|
||||
{
|
||||
// Arrange - Expression with enum comparison
|
||||
System.Linq.Expressions.Expression<Func<TestOrderItem, bool>> filterExpression =
|
||||
item => item.Status == TestStatus.Completed;
|
||||
|
||||
var originalNode = AcExpressionConverter.ToNode(filterExpression);
|
||||
|
||||
// Act - Binary round-trip
|
||||
var bytes = AcBinarySerializer.Serialize(originalNode);
|
||||
var deserializedNode = AcBinaryDeserializer.Deserialize<AcExpressionNode>(bytes);
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(deserializedNode);
|
||||
|
||||
var rebuiltExpression = AcExpressionRebuilder.FromNode<TestOrderItem, bool>(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
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for JSON Chain API (CreateDeserializeChain and CreatePopulateChain).
|
||||
/// </summary>
|
||||
[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<TestSimpleClass>();
|
||||
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<TestSimpleClass>();
|
||||
var result1 = chain.Value;
|
||||
var result2 = chain.ThenDeserialize<TestSimpleClass>();
|
||||
var result3 = chain.ThenDeserialize<TestSimpleClass>();
|
||||
|
||||
// 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<TestSimpleClass>();
|
||||
var asClass = chain.Value;
|
||||
var asDict = chain.ThenDeserialize<Dictionary<string, object>>();
|
||||
|
||||
// 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<TestNestedClass>();
|
||||
var result1 = chain.Value;
|
||||
var result2 = chain.ThenDeserialize<TestNestedClass>();
|
||||
|
||||
// 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<string> { "Apple", "Banana", "Cherry" }
|
||||
};
|
||||
var json = original.ToJson();
|
||||
|
||||
// Act
|
||||
using var chain = json.JsonToChain<TestClassWithList>();
|
||||
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<string> { "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<TestSimpleClass>();
|
||||
|
||||
// Assert
|
||||
Assert.IsNull(chain.Value);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DeserializeChain_NullJson_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var json = "null";
|
||||
|
||||
// Act
|
||||
using var chain = json.JsonToChain<TestSimpleClass>();
|
||||
|
||||
// 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<TestSimpleClass>();
|
||||
var value = chain.Value;
|
||||
|
||||
// Act
|
||||
chain.Dispose();
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(value); // Value from before dispose should still exist
|
||||
_ = Assert.ThrowsExactly<ObjectDisposedException>(() => chain.ThenDeserialize<TestSimpleClass>());
|
||||
}
|
||||
|
||||
[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<ObjectDisposedException>(() => 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<TestSimpleClass>(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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
using static AyCode.Core.Tests.Serialization.AcSerializerModels;
|
||||
using static AyCode.Core.Tests.TestModels.AcSerializerModels;
|
||||
|
||||
namespace AyCode.Core.Tests.Serialization;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
namespace AyCode.Core.Tests.Serialization;
|
||||
namespace AyCode.Core.Tests.TestModels;
|
||||
|
||||
/// <summary>
|
||||
/// Test models for binary serializer tests.
|
||||
|
|
@ -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
|
||||
|
|
@ -5,7 +5,7 @@ using AyCode.Core.Loggers;
|
|||
namespace AyCode.Core.Tests.TestModels;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -17,4 +17,8 @@
|
|||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Expressions\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -447,6 +447,46 @@ public static class SerializeObjectExtensions
|
|||
AcJsonDeserializer.Populate(json, target, options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static IDeserializeChain<T> JsonToChain<T>(this string json)
|
||||
{
|
||||
json = UnwrapJsonString(json);
|
||||
return AcJsonDeserializer.CreateDeserializeChain<T>(json);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a deserialize chain with options.
|
||||
/// </summary>
|
||||
public static IDeserializeChain<T> JsonToChain<T>(this string json, AcJsonSerializerOptions options)
|
||||
{
|
||||
json = UnwrapJsonString(json);
|
||||
return AcJsonDeserializer.CreateDeserializeChain<T>(json, options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static IPopulateChain JsonToChain(this string json, object target)
|
||||
{
|
||||
json = UnwrapJsonString(json);
|
||||
return AcJsonDeserializer.CreatePopulateChain(json, target);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a populate chain with options.
|
||||
/// </summary>
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class AcSerializerCommon
|
||||
{
|
||||
#region Type References
|
||||
|
||||
/// <summary>
|
||||
/// Expression base type for type checking.
|
||||
/// </summary>
|
||||
public static readonly Type ExpressionType = typeof(LExpression);
|
||||
|
||||
/// <summary>
|
||||
/// Generic Expression<TDelegate> type definition.
|
||||
/// </summary>
|
||||
public static readonly Type ExpressionGenericType = typeof(Expression<>);
|
||||
|
||||
/// <summary>
|
||||
/// Generic IQueryable<T> type definition.
|
||||
/// </summary>
|
||||
public static readonly Type QueryableGenericType = typeof(IQueryable<>);
|
||||
|
||||
/// <summary>
|
||||
/// Non-generic IQueryable type.
|
||||
/// </summary>
|
||||
public static readonly Type QueryableType = typeof(IQueryable);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Type Checking
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a type is an Expression type.
|
||||
/// Shared across JSON and Binary serializers.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static bool IsExpressionType(Type type)
|
||||
{
|
||||
return ExpressionType.IsAssignableFrom(type);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a type is Expression<TDelegate> (e.g., Expression<Func<T, bool>>).
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static bool IsGenericExpressionType(Type type)
|
||||
{
|
||||
return type.IsGenericType && type.GetGenericTypeDefinition() == ExpressionGenericType;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a type is IQueryable or IQueryable<T>.
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the entity type from IQueryable<TEntity>.
|
||||
/// Returns null if not a valid queryable type.
|
||||
/// </summary>
|
||||
public static Type? GetQueryableEntityType(Type queryableType)
|
||||
{
|
||||
if (queryableType.IsGenericType && queryableType.GetGenericTypeDefinition() == QueryableGenericType)
|
||||
return queryableType.GetGenericArguments()[0];
|
||||
|
||||
// Check interfaces for IQueryable<T>
|
||||
foreach (var iface in queryableType.GetInterfaces())
|
||||
{
|
||||
if (iface.IsGenericType && iface.GetGenericTypeDefinition() == QueryableGenericType)
|
||||
return iface.GetGenericArguments()[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the entity type from Expression<Func<TEntity, TResult>>.
|
||||
/// Returns null if not a valid expression type.
|
||||
/// </summary>
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
/// Converts an IQueryable to AcExpressionNode for serialization.
|
||||
/// The query's expression tree is serialized.
|
||||
/// </summary>
|
||||
public static AcExpressionNode QueryableToNode(IQueryable queryable)
|
||||
{
|
||||
return AcExpressionConverter.ToNode(queryable.Expression);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies an AcExpressionNode to an IQueryable source.
|
||||
/// Used to rebuild the query on the server side.
|
||||
/// </summary>
|
||||
public static IQueryable<T> ApplyQueryFromNode<T>(IQueryable<T> source, AcExpressionNode node)
|
||||
{
|
||||
var expression = RebuildQueryExpression(source.Expression, node, typeof(T));
|
||||
return source.Provider.CreateQuery<T>(expression);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes an aggregate query (Count, Sum, Any, etc.) from an AcExpressionNode.
|
||||
/// Returns the scalar result of the query.
|
||||
/// </summary>
|
||||
public static object? ExecuteQueryFromNode<T>(IQueryable<T> source, AcExpressionNode node)
|
||||
{
|
||||
var expression = RebuildQueryExpression(source.Expression, node, typeof(T));
|
||||
return source.Provider.Execute(expression);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds a query expression, replacing the source with the provided expression.
|
||||
/// </summary>
|
||||
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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds a chain of method calls (Where, OrderBy, Skip, Take, etc.)
|
||||
/// Automatically replaces the original IQueryable source with the server's source.
|
||||
/// </summary>
|
||||
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<LExpression> { 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.)
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a type from its name.
|
||||
/// Supports AssemblyQualifiedName, FullName, and searches all loaded assemblies as fallback.
|
||||
/// </summary>
|
||||
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<string>? 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
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds an Expression from AcExpressionNode for the target Expression type.
|
||||
/// Used by both JSON and Binary deserializers.
|
||||
/// </summary>
|
||||
/// <param name="node">The serialized expression node</param>
|
||||
/// <param name="targetExpressionType">The target Expression<TDelegate> type</param>
|
||||
/// <returns>The rebuilt Expression, or null if conversion fails</returns>
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
/// Creates a compiled getter for a property using expression trees.
|
||||
/// Shared across all TypeMetadata implementations.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static Func<object, object?> 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<Func<object, object?>>(boxed, objParam).Compile();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a compiled setter for a property using expression trees.
|
||||
/// Handles nullable value types correctly.
|
||||
/// </summary>
|
||||
public static Action<object, object?> 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<Action<object, object?>>(assign, targetParam, valueParam).Compile();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a compiled parameterless constructor for a type.
|
||||
/// Returns null if type is abstract or has no parameterless constructor.
|
||||
/// </summary>
|
||||
public static Func<object>? 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<Func<object>>(convert).Compile();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a typed getter delegate to avoid boxing for value types.
|
||||
/// </summary>
|
||||
public static Func<object, TProperty> CreateTypedGetter<TProperty>(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<Func<object, TProperty>>(convertExpr, objParam).Compile();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an enum getter that returns int to avoid boxing.
|
||||
/// </summary>
|
||||
public static Func<object, int> 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<Func<object, int>>(convertToInt, objParam).Compile();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
|
@ -32,24 +32,12 @@ public static partial class AcBinaryDeserializer
|
|||
? FrozenDictionary<string, BinaryPropertySetterInfo>.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<object>? 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<Func<object>>(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<object, object?> 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<Func<object, object?>>(boxed, targetParam).Compile();
|
||||
}
|
||||
|
||||
private static Action<object, object?> 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<Action<object, object?>>(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserialize Expression from binary data.
|
||||
/// First deserializes as AcExpressionNode, then rebuilds the Expression tree.
|
||||
/// </summary>
|
||||
private static Expression? DeserializeExpression(ReadOnlySpan<byte> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Populate existing object from binary data.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -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<object, int>)_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<Guid>(declaringType, prop), PropertyAccessorType.Guid);
|
||||
return (AcSerializerCommon.CreateTypedGetter<Guid>(declaringType, prop), PropertyAccessorType.Guid);
|
||||
}
|
||||
|
||||
var typeCode = Type.GetTypeCode(propType);
|
||||
return typeCode switch
|
||||
{
|
||||
TypeCode.Int32 => (CreateTypedGetterDelegate<int>(declaringType, prop), PropertyAccessorType.Int32),
|
||||
TypeCode.Int64 => (CreateTypedGetterDelegate<long>(declaringType, prop), PropertyAccessorType.Int64),
|
||||
TypeCode.Boolean => (CreateTypedGetterDelegate<bool>(declaringType, prop), PropertyAccessorType.Boolean),
|
||||
TypeCode.Double => (CreateTypedGetterDelegate<double>(declaringType, prop), PropertyAccessorType.Double),
|
||||
TypeCode.Single => (CreateTypedGetterDelegate<float>(declaringType, prop), PropertyAccessorType.Single),
|
||||
TypeCode.Decimal => (CreateTypedGetterDelegate<decimal>(declaringType, prop), PropertyAccessorType.Decimal),
|
||||
TypeCode.DateTime => (CreateTypedGetterDelegate<DateTime>(declaringType, prop), PropertyAccessorType.DateTime),
|
||||
TypeCode.Byte => (CreateTypedGetterDelegate<byte>(declaringType, prop), PropertyAccessorType.Byte),
|
||||
TypeCode.Int16 => (CreateTypedGetterDelegate<short>(declaringType, prop), PropertyAccessorType.Int16),
|
||||
TypeCode.UInt16 => (CreateTypedGetterDelegate<ushort>(declaringType, prop), PropertyAccessorType.UInt16),
|
||||
TypeCode.UInt32 => (CreateTypedGetterDelegate<uint>(declaringType, prop), PropertyAccessorType.UInt32),
|
||||
TypeCode.UInt64 => (CreateTypedGetterDelegate<ulong>(declaringType, prop), PropertyAccessorType.UInt64),
|
||||
TypeCode.Int32 => (AcSerializerCommon.CreateTypedGetter<int>(declaringType, prop), PropertyAccessorType.Int32),
|
||||
TypeCode.Int64 => (AcSerializerCommon.CreateTypedGetter<long>(declaringType, prop), PropertyAccessorType.Int64),
|
||||
TypeCode.Boolean => (AcSerializerCommon.CreateTypedGetter<bool>(declaringType, prop), PropertyAccessorType.Boolean),
|
||||
TypeCode.Double => (AcSerializerCommon.CreateTypedGetter<double>(declaringType, prop), PropertyAccessorType.Double),
|
||||
TypeCode.Single => (AcSerializerCommon.CreateTypedGetter<float>(declaringType, prop), PropertyAccessorType.Single),
|
||||
TypeCode.Decimal => (AcSerializerCommon.CreateTypedGetter<decimal>(declaringType, prop), PropertyAccessorType.Decimal),
|
||||
TypeCode.DateTime => (AcSerializerCommon.CreateTypedGetter<DateTime>(declaringType, prop), PropertyAccessorType.DateTime),
|
||||
TypeCode.Byte => (AcSerializerCommon.CreateTypedGetter<byte>(declaringType, prop), PropertyAccessorType.Byte),
|
||||
TypeCode.Int16 => (AcSerializerCommon.CreateTypedGetter<short>(declaringType, prop), PropertyAccessorType.Int16),
|
||||
TypeCode.UInt16 => (AcSerializerCommon.CreateTypedGetter<ushort>(declaringType, prop), PropertyAccessorType.UInt16),
|
||||
TypeCode.UInt32 => (AcSerializerCommon.CreateTypedGetter<uint>(declaringType, prop), PropertyAccessorType.UInt32),
|
||||
TypeCode.UInt64 => (AcSerializerCommon.CreateTypedGetter<ulong>(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<Func<object, int>>(convertToInt, objParam).Compile();
|
||||
}
|
||||
|
||||
private static Func<object, TProperty> CreateTypedGetterDelegate<TProperty>(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<Func<object, TProperty>>(convertExpr, objParam).Compile();
|
||||
}
|
||||
|
||||
private static Func<object, object?> 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<Func<object, object?>>(boxed, objParam).Compile();
|
||||
}
|
||||
}
|
||||
|
||||
internal enum PropertyAccessorType : byte
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/// </summary>
|
||||
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
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a type is an Expression type.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static bool IsExpressionType(Type type)
|
||||
{
|
||||
return ExpressionBaseType.IsAssignableFrom(type);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the serialized size without allocating the final array.
|
||||
/// Useful for pre-allocating buffers.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,435 @@
|
|||
using System.Linq.Expressions;
|
||||
using System.Reflection;
|
||||
|
||||
namespace AyCode.Core.Serializers.Expressions;
|
||||
|
||||
/// <summary>
|
||||
/// Converts Expression trees to AcExpressionNode DTO.
|
||||
/// Format-agnostic - produces a DTO that can be serialized with any serializer.
|
||||
/// </summary>
|
||||
public class AcExpressionConverter : ExpressionVisitor
|
||||
{
|
||||
private readonly Dictionary<ParameterExpression, int> _parameterIndexes = new();
|
||||
private int _nextParameterIndex;
|
||||
|
||||
// Stack to collect converted nodes
|
||||
private readonly Stack<AcExpressionNode> _nodeStack = new();
|
||||
|
||||
/// <summary>
|
||||
/// Converts an Expression to an AcExpressionNode DTO.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Static convenience method to convert an Expression to AcExpressionNode.
|
||||
/// </summary>
|
||||
public static AcExpressionNode ToNode(Expression expression)
|
||||
{
|
||||
var converter = new AcExpressionConverter();
|
||||
return converter.Convert(expression);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Static convenience method to convert a typed Expression to AcExpressionNode.
|
||||
/// </summary>
|
||||
public static AcExpressionNode ToNode<TEntity, TResult>(Expression<Func<TEntity, TResult>> 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<T>(Expression<T> node)
|
||||
{
|
||||
// Register parameters with indexes
|
||||
var parameters = new List<ParameterNode>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a constant node with type-safe value storage.
|
||||
/// </summary>
|
||||
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<AcExpressionNode>();
|
||||
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<AcExpressionNode>();
|
||||
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<AcExpressionNode>();
|
||||
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<AcExpressionNode>();
|
||||
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<AcExpressionNode>();
|
||||
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<MemberExpression>();
|
||||
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
|
||||
}
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
using System.Linq.Expressions;
|
||||
|
||||
namespace AyCode.Core.Serializers.Expressions;
|
||||
|
||||
/// <summary>
|
||||
/// Helper class for converting Expression trees to/from AcExpressionNode DTO.
|
||||
/// Format-agnostic - use AcJsonSerializer or AcBinarySerializer to serialize the nodes.
|
||||
/// </summary>
|
||||
public static class AcExpressionHelper
|
||||
{
|
||||
#region Expression Conversion
|
||||
|
||||
/// <summary>
|
||||
/// Converts an Expression to AcExpressionNode DTO.
|
||||
/// </summary>
|
||||
public static AcExpressionNode ToNode(Expression expression)
|
||||
{
|
||||
return AcExpressionConverter.ToNode(expression);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a typed Expression to AcExpressionNode DTO.
|
||||
/// </summary>
|
||||
public static AcExpressionNode ToNode<TEntity, TResult>(Expression<Func<TEntity, TResult>> expression)
|
||||
{
|
||||
return AcExpressionConverter.ToNode(expression);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds an Expression from AcExpressionNode DTO.
|
||||
/// </summary>
|
||||
public static Expression FromNode(AcExpressionNode node, Type? entityType = null)
|
||||
{
|
||||
return AcExpressionRebuilder.FromNode(node, entityType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds a typed Expression from AcExpressionNode DTO.
|
||||
/// </summary>
|
||||
public static Expression<Func<TEntity, TResult>> FromNode<TEntity, TResult>(AcExpressionNode node)
|
||||
{
|
||||
return AcExpressionRebuilder.FromNode<TEntity, TResult>(node);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IQueryable Conversion
|
||||
|
||||
/// <summary>
|
||||
/// Converts an IQueryable's expression tree to AcExpressionNode.
|
||||
/// </summary>
|
||||
public static AcExpressionNode QueryToNode<T>(IQueryable<T> query)
|
||||
{
|
||||
return ToNode(query.Expression);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies an AcExpressionNode query to an IQueryable source.
|
||||
/// </summary>
|
||||
public static IQueryable<T> ApplyQueryFromNode<T>(IQueryable<T> source, AcExpressionNode node)
|
||||
{
|
||||
var expression = RebuildQueryExpression(source.Expression, node, typeof(T));
|
||||
return source.Provider.CreateQuery<T>(expression);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Private Helpers
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds a query expression, replacing the source with the provided expression.
|
||||
/// </summary>
|
||||
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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds a chain of method calls (Where, OrderBy, Skip, Take, etc.)
|
||||
/// </summary>
|
||||
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<Expression> { 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<string>? 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
|
||||
}
|
||||
|
|
@ -0,0 +1,334 @@
|
|||
using System.Linq.Expressions;
|
||||
|
||||
namespace AyCode.Core.Serializers.Expressions;
|
||||
|
||||
/// <summary>
|
||||
/// Constant value type for serialization.
|
||||
/// Determines which typed property contains the actual value.
|
||||
/// </summary>
|
||||
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,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Universal DTO representing any Expression node.
|
||||
/// Recursively represents the entire Expression tree.
|
||||
/// Format-agnostic - can be serialized with any serializer (JSON, Binary, etc.)
|
||||
/// </summary>
|
||||
public sealed class AcExpressionNode
|
||||
{
|
||||
/// <summary>
|
||||
/// The expression node type (Add, Equal, Call, MemberAccess, Lambda, etc.)
|
||||
/// </summary>
|
||||
public ExpressionType NodeType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The CLR type name of this expression's result.
|
||||
/// </summary>
|
||||
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<ParameterNode>? 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<AcExpressionNode>? Arguments { get; set; }
|
||||
public List<string>? 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; }
|
||||
|
||||
/// <summary>
|
||||
/// For enum values, stores the assembly-qualified enum type name.
|
||||
/// </summary>
|
||||
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<AcExpressionNode>? ConstructorArguments { get; set; }
|
||||
public List<MemberBindingNode>? MemberBindings { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region Array/Collection
|
||||
|
||||
public List<AcExpressionNode>? Elements { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region Value Helpers
|
||||
|
||||
/// <summary>
|
||||
/// Gets the constant value based on ValueType.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the constant value with automatic type detection.
|
||||
/// </summary>
|
||||
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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a parameter definition in a lambda expression.
|
||||
/// </summary>
|
||||
public sealed class ParameterNode
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string TypeName { get; set; } = "";
|
||||
public int Index { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a member binding in MemberInit expressions.
|
||||
/// </summary>
|
||||
public sealed class MemberBindingNode
|
||||
{
|
||||
public string MemberName { get; set; } = "";
|
||||
public MemberBindingType BindingType { get; set; }
|
||||
public AcExpressionNode? Expression { get; set; }
|
||||
public List<MemberBindingNode>? Bindings { get; set; }
|
||||
public List<List<AcExpressionNode>>? Initializers { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,360 @@
|
|||
using System.Linq.Expressions;
|
||||
using System.Reflection;
|
||||
|
||||
namespace AyCode.Core.Serializers.Expressions;
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds Expression trees from AcExpressionNode DTO.
|
||||
/// Format-agnostic - works with any deserialized AcExpressionNode.
|
||||
/// </summary>
|
||||
public class AcExpressionRebuilder
|
||||
{
|
||||
private readonly Dictionary<int, ParameterExpression> _parameters = new();
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds an Expression from AcExpressionNode.
|
||||
/// </summary>
|
||||
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.")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Static convenience method to rebuild an Expression from AcExpressionNode.
|
||||
/// </summary>
|
||||
public static Expression FromNode(AcExpressionNode node, Type? entityType = null)
|
||||
{
|
||||
var rebuilder = new AcExpressionRebuilder();
|
||||
return rebuilder.Rebuild(node, entityType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Static convenience method to rebuild a typed Expression from AcExpressionNode.
|
||||
/// </summary>
|
||||
public static Expression<Func<TEntity, TResult>> FromNode<TEntity, TResult>(AcExpressionNode node)
|
||||
{
|
||||
var expression = FromNode(node, typeof(TEntity));
|
||||
return (Expression<Func<TEntity, TResult>>)expression;
|
||||
}
|
||||
|
||||
#region Rebuild Methods
|
||||
|
||||
private LambdaExpression RebuildLambda(AcExpressionNode node, Type? entityType)
|
||||
{
|
||||
// Create parameters
|
||||
var parameters = new List<ParameterExpression>();
|
||||
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
|
||||
}
|
||||
|
|
@ -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<DeserializationContext> 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<string, object>? _idToObject;
|
||||
private List<PropertyToResolve>? _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<string, object>(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<PropertyToResolve>(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Type, DeserializeTypeMetadata> 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<string, PropertySetterInfo> PropertySettersFrozen { get; }
|
||||
public PropertySetterInfo[] PropertiesArray { get; } // Array for fast UTF8 linear scan (small objects)
|
||||
public Func<object>? CompiledConstructor { get; }
|
||||
|
||||
public DeserializeTypeMetadata(Type type)
|
||||
{
|
||||
CompiledConstructor = AcSerializerCommon.CreateCompiledConstructor(type);
|
||||
|
||||
var allProps = type.GetProperties(BindingFlags.Public | BindingFlags.Instance);
|
||||
var propsList = new List<PropertyInfo>(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<string, PropertySetterInfo>(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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Try to find property by UTF-8 name using ValueTextEquals (avoids string allocation).
|
||||
/// </summary>
|
||||
[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<object, object?>? 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<object, object?> _setter;
|
||||
private readonly Func<object, object?> _getter;
|
||||
|
||||
// Typed setters for common primitive types (avoid boxing)
|
||||
internal readonly Action<object, int>? _setInt32;
|
||||
internal readonly Action<object, long>? _setInt64;
|
||||
internal readonly Action<object, double>? _setDouble;
|
||||
internal readonly Action<object, bool>? _setBool;
|
||||
internal readonly Action<object, decimal>? _setDecimal;
|
||||
internal readonly Action<object, float>? _setSingle;
|
||||
internal readonly Action<object, DateTime>? _setDateTime;
|
||||
internal readonly Action<object, Guid>? _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<int>(declaringType, prop);
|
||||
else if (ReferenceEquals(PropertyType, LongType))
|
||||
_setInt64 = CreateTypedSetter<long>(declaringType, prop);
|
||||
else if (ReferenceEquals(PropertyType, DoubleType))
|
||||
_setDouble = CreateTypedSetter<double>(declaringType, prop);
|
||||
else if (ReferenceEquals(PropertyType, BoolType))
|
||||
_setBool = CreateTypedSetter<bool>(declaringType, prop);
|
||||
else if (ReferenceEquals(PropertyType, DecimalType))
|
||||
_setDecimal = CreateTypedSetter<decimal>(declaringType, prop);
|
||||
else if (ReferenceEquals(PropertyType, FloatType))
|
||||
_setSingle = CreateTypedSetter<float>(declaringType, prop);
|
||||
else if (ReferenceEquals(PropertyType, DateTimeType))
|
||||
_setDateTime = CreateTypedSetter<DateTime>(declaringType, prop);
|
||||
else if (ReferenceEquals(PropertyType, GuidType))
|
||||
_setGuid = CreateTypedSetter<Guid>(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<object, T> CreateTypedSetter<T>(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<Action<object, T>>(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);
|
||||
|
||||
/// <summary>
|
||||
/// Read and set value directly from Utf8JsonReader, avoiding boxing for primitives.
|
||||
/// Returns true if value was set, false if it needs fallback to SetValue.
|
||||
/// </summary>
|
||||
[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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<T> */ }
|
||||
|
||||
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<object, object>? existingById = null;
|
||||
if (count > 0)
|
||||
{
|
||||
existingById = new Dictionary<object, object>(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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
/// <summary>
|
||||
/// Deserialize using Utf8JsonReader - streaming without DOM allocation.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static T? DeserializeWithUtf8Reader<T>(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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read number value using cached PropertySetterInfo (avoids Nullable.GetUnderlyingType + Type.GetTypeCode calls).
|
||||
/// </summary>
|
||||
[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()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read string value using cached PropertySetterInfo (avoids Nullable.GetUnderlyingType calls).
|
||||
/// </summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read value from reader using cached PropertySetterInfo for faster type resolution.
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read object from reader when we're already at StartObject.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Populate object using Utf8JsonReader streaming (no DOM allocation).
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Populate list using Utf8JsonReader streaming (no DOM allocation).
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Populate object with merge semantics from Utf8JsonReader.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read primitive value from Utf8JsonReader using cached PropertySetterInfo.
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merge IId collection from Utf8JsonReader streaming.
|
||||
/// </summary>
|
||||
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<object, object>? existingById = null;
|
||||
if (count > 0)
|
||||
{
|
||||
existingById = new Dictionary<object, object>(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(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copy properties from source to target using metadata.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read primitive value from Utf8JsonReader.
|
||||
/// </summary>
|
||||
[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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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<SerializationContext> 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<byte> _buffer;
|
||||
public Utf8JsonWriter Writer { get; private set; }
|
||||
|
||||
private Dictionary<object, int>? _scanOccurrences;
|
||||
private Dictionary<object, string>? _writtenRefs;
|
||||
private HashSet<object>? _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<byte>(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<object, int>(64, ReferenceEqualityComparer.Instance);
|
||||
_writtenRefs ??= new Dictionary<object, string>(32, ReferenceEqualityComparer.Instance);
|
||||
_multiReferenced ??= new HashSet<object>(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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference equality comparer for object identity comparison.
|
||||
/// </summary>
|
||||
internal sealed class ReferenceEqualityComparer : IEqualityComparer<object>
|
||||
{
|
||||
public static readonly ReferenceEqualityComparer Instance = new();
|
||||
public new bool Equals(object? x, object? y) => ReferenceEquals(x, y);
|
||||
public int GetHashCode(object obj) => RuntimeHelpers.GetHashCode(obj);
|
||||
}
|
||||
|
|
@ -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<Type, TypeMetadata> 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<object, object?> _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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
|||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class AcJsonSerializer
|
||||
public static partial class AcJsonSerializer
|
||||
{
|
||||
private static readonly ConcurrentDictionary<Type, TypeMetadata> 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);
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a type is an Expression type.
|
||||
/// </summary>
|
||||
[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<object, object?> _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<object, object?> CreateCompiledGetter(Type declaringType, PropertyInfo prop)
|
||||
{
|
||||
var objParam = Expression.Parameter(typeof(object), "obj");
|
||||
var castExpr = Expression.Convert(objParam, declaringType);
|
||||
var propAccess = Expression.Property(castExpr, prop);
|
||||
var boxed = Expression.Convert(propAccess, typeof(object));
|
||||
return Expression.Lambda<Func<object, object?>>(boxed, objParam).Compile();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public object? GetValue(object obj) => _getter(obj);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Context Pool
|
||||
|
||||
private static class SerializationContextPool
|
||||
{
|
||||
private static readonly ConcurrentQueue<SerializationContext> 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<byte> _buffer;
|
||||
public Utf8JsonWriter Writer { get; private set; }
|
||||
|
||||
private Dictionary<object, int>? _scanOccurrences;
|
||||
private Dictionary<object, string>? _writtenRefs;
|
||||
private HashSet<object>? _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<byte>(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<object, int>(64, ReferenceEqualityComparer.Instance);
|
||||
_writtenRefs ??= new Dictionary<object, string>(32, ReferenceEqualityComparer.Instance);
|
||||
_multiReferenced ??= new HashSet<object>(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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference equality comparer for object identity comparison.
|
||||
/// </summary>
|
||||
internal sealed class ReferenceEqualityComparer : IEqualityComparer<object>
|
||||
{
|
||||
public static readonly ReferenceEqualityComparer Instance = new();
|
||||
public new bool Equals(object? x, object? y) => ReferenceEquals(x, y);
|
||||
public int GetHashCode(object obj) => RuntimeHelpers.GetHashCode(obj);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<TDataSource, TIList>
|
||||
{
|
||||
#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<TestOrderItem>));
|
||||
}
|
||||
|
||||
#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<TIList>();
|
||||
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<ArgumentOutOfRangeException>(() => _ = 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
|
||||
}
|
||||
|
|
@ -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<TDataSource, TIList>
|
||||
{
|
||||
#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
|
||||
|
||||
/// <summary>
|
||||
/// Creates CRUD tags for Expression-enabled DataSource.
|
||||
/// </summary>
|
||||
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<Func<TestOrderItem, bool>> 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<Func<TestOrderItem, bool>> 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<Func<TestOrderItem, bool>> 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<Func<TestOrderItem, bool>> 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
|
||||
|
||||
/// <summary>
|
||||
/// Creates CRUD tags for IQueryable-enabled DataSource.
|
||||
/// </summary>
|
||||
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<Func<TestOrderItem, bool>> 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<Func<TestOrderItem, bool>> 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<Func<TestOrderItem, bool>> 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<TestOrderItem>().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<TestOrderItem>
|
||||
{
|
||||
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<TestOrderItem>().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<TestOrderItem>
|
||||
{
|
||||
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<TestOrderItem>().AsQueryable();
|
||||
Expression<Func<TestOrderItem, bool>> 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<TestOrderItem>
|
||||
{
|
||||
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<TestOrderItem>().AsQueryable();
|
||||
Expression<Func<TestOrderItem, bool>> 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<TestOrderItem>
|
||||
{
|
||||
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<TestOrderItem>().AsQueryable();
|
||||
Expression<Func<TestOrderItem, int>> 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<TestOrderItem>().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<TestOrderItem>().AsQueryable().Where(x => x.Id > 1);
|
||||
var queryNode = AcExpressionConverter.ToNode(clientQuery.Expression);
|
||||
|
||||
// Server data
|
||||
var serverItems = new List<TestOrderItem>
|
||||
{
|
||||
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
|
||||
}
|
||||
|
|
@ -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<TDataSource, TIList>
|
||||
{
|
||||
#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<ArgumentException>(() =>
|
||||
{
|
||||
dataSource.Add(new TestOrderItem { Id = 100, ProductName = "Duplicate" });
|
||||
});
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public virtual void Add_DefaultId_ThrowsException()
|
||||
{
|
||||
var dataSource = CreateDataSource(_client, _crudTags);
|
||||
|
||||
Assert.ThrowsExactly<ArgumentNullException>(() =>
|
||||
{
|
||||
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
|
||||
}
|
||||
|
|
@ -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<TDataSource, TIList>
|
||||
{
|
||||
#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
|
||||
}
|
||||
|
|
@ -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<TDataSource, TIList>
|
||||
{
|
||||
#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<TestOrderItem>? 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
|
||||
}
|
||||
|
|
@ -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<TDataSource, TIList>
|
||||
{
|
||||
#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
|
||||
}
|
||||
|
|
@ -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<TDataSource, TIList>
|
||||
{
|
||||
#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
|
||||
}
|
||||
|
|
@ -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
|
||||
/// <summary>
|
||||
/// 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;
|
|||
/// </summary>
|
||||
/// <typeparam name="TDataSource">The concrete DataSource type</typeparam>
|
||||
/// <typeparam name="TIList">The inner list type (List or AcObservableCollection)</typeparam>
|
||||
public abstract class SignalRDataSourceTestBase<TDataSource, TIList>
|
||||
public abstract partial class SignalRDataSourceTestBase<TDataSource, TIList>
|
||||
where TDataSource : AcSignalRDataSource<TestOrderItem, int, TIList>
|
||||
where TIList : class, IList<TestOrderItem>
|
||||
{
|
||||
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<TDataSource, TIList>
|
|||
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<TestOrderItem>? 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<ArgumentException>(() =>
|
||||
{
|
||||
dataSource.Add(new TestOrderItem { Id = 100, ProductName = "Duplicate" });
|
||||
});
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public virtual void Add_DefaultId_ThrowsException()
|
||||
{
|
||||
var dataSource = CreateDataSource(_client, _crudTags);
|
||||
|
||||
Assert.ThrowsExactly<ArgumentNullException>(() =>
|
||||
{
|
||||
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<TestOrderItem>));
|
||||
}
|
||||
#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<TIList>();
|
||||
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<ArgumentOutOfRangeException>(() => _ = 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
|
||||
}
|
||||
|
|
@ -160,4 +160,16 @@ public class SignalRDataSourceTests_List_Json : SignalRDataSourceTestBase<TestOr
|
|||
public override void Add_ThenRemove_ClearsTracking() => 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();
|
||||
}
|
||||
|
|
@ -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<TestOrderItem> DataSourceGetAll() => _dataSourceItems.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// DataSource GetAll with Expression filter.
|
||||
/// The deserializer automatically converts AcExpressionNode to Expression.
|
||||
/// Uses IQueryable.Where() instead of Compile() for EF Core compatibility.
|
||||
/// </summary>
|
||||
[SignalR(TestSignalRTags.ExpressionDataSourceGetAll)]
|
||||
public List<TestOrderItem> ExpressionDataSourceGetAll(Expression<Func<TestOrderItem, bool>>? 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[SignalR(TestSignalRTags.QueryableDataSourceGetAll)]
|
||||
public List<TestOrderItem> QueryableDataSourceGetAll(AcExpressionNode? queryNode = null)
|
||||
{
|
||||
// Simulate DbContext.Items (IQueryable<T>)
|
||||
IQueryable<TestOrderItem> 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<TestOrderItem, bool>(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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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!
|
||||
/// </summary>
|
||||
[SignalR(TestSignalRTags.QueryableDataSourceAggregate)]
|
||||
public object? QueryableDataSourceAggregate(AcExpressionNode queryNode)
|
||||
{
|
||||
// Build the query from the expression node
|
||||
IQueryable<TestOrderItem> 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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -327,12 +327,30 @@ public abstract class AcWebSignalRHubBase<TSignalRTags, TLogger>(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<SignalPostJsonMessage>(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];
|
||||
|
|
|
|||
|
|
@ -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<Person>())
|
||||
{
|
||||
var person = chain.Value;
|
||||
Console.WriteLine($"Person: {person?.Name}, {person?.Age}");
|
||||
|
||||
var dict = chain.ThenDeserialize<Dictionary<string, object>>();
|
||||
Console.WriteLine($"Dict: {dict?.Count} keys");
|
||||
|
||||
var person2 = chain.ThenDeserialize<Person>();
|
||||
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; } = "";
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AyCode.Core\AyCode.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Loading…
Reference in New Issue