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:
Loretta 2025-12-29 15:28:46 +01:00
parent 9fad870960
commit 9f8c027366
50 changed files with 6339 additions and 2751 deletions

View File

@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(dotnet build:*)",
"Bash(dotnet test:*)"
]
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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
}

View File

@ -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);
}
}

View File

@ -1,4 +1,4 @@
using static AyCode.Core.Tests.Serialization.AcSerializerModels;
using static AyCode.Core.Tests.TestModels.AcSerializerModels;
namespace AyCode.Core.Tests.Serialization;

View File

@ -1,4 +1,4 @@
namespace AyCode.Core.Tests.Serialization;
namespace AyCode.Core.Tests.TestModels;
/// <summary>
/// Test models for binary serializer tests.

View File

@ -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

View File

@ -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();

View File

@ -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

View File

@ -17,4 +17,8 @@
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
<ItemGroup>
<Folder Include="Expressions\" />
</ItemGroup>
</Project>

View File

@ -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)

View File

@ -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&lt;TDelegate&gt; type definition.
/// </summary>
public static readonly Type ExpressionGenericType = typeof(Expression<>);
/// <summary>
/// Generic IQueryable&lt;T&gt; 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&lt;TDelegate&gt; (e.g., Expression&lt;Func&lt;T, bool&gt;&gt;).
/// </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&lt;T&gt;.
/// </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&lt;TEntity&gt;.
/// 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&lt;Func&lt;TEntity, TResult&gt;&gt;.
/// 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&lt;TDelegate&gt; 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
}

View File

@ -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;
}
}
}

View File

@ -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>

View File

@ -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

View File

@ -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.

View File

@ -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
}

View File

@ -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
}

View File

@ -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; }
}

View File

@ -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
}

View File

@ -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);
}
}
}
}

View File

@ -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
}
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -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&lt;T&gt; 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);
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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();
}

View File

@ -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);

View File

@ -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;
}

View File

@ -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];

48
TestChainApi.cs Normal file
View File

@ -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; } = "";
}

View File

@ -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>