Add LINQ Expression JSON serialization & SignalR grid source

- Introduce AcExpressionHelper and related classes for serializing/deserializing LINQ Expression trees and IQueryable queries to/from JSON, enabling remote transport and execution.
- Add MgGridSignalRDataSource<TDataItem, TId> for instant local filtering and background refresh in DevExpress grids using SignalR.
- Update bunit NuGet package to v2.4.2 in test projects.
- Minor: update FruitBank base URL comment, add new test in OrderClientTests.
This commit is contained in:
Loretta 2025-12-30 19:29:50 +01:00
parent 500e39a514
commit b6f51bc2a1
6 changed files with 1700 additions and 1 deletions

View File

@ -8,7 +8,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="bunit" Version="2.3.4" /> <PackageReference Include="bunit" Version="2.4.2" />
<PackageReference Include="MSTest" Version="4.0.2" /> <PackageReference Include="MSTest" Version="4.0.2" />
</ItemGroup> </ItemGroup>

View File

@ -0,0 +1,462 @@
using AyCode.Core.Helpers;
using AyCode.Core.Interfaces;
using AyCode.Core.Loggers;
using AyCode.Services.Server.SignalRs;
using AyCode.Services.SignalRs;
using DevExpress.Blazor;
using DevExpress.Data.Filtering;
using DevExpress.Data.Linq;
using DevExpress.Data.Linq.Helpers;
using System.Collections;
namespace AyCode.Blazor.Components.Components.Grids;
#region Models
/// <summary>
/// Sorting information for a single field
/// </summary>
public class SignalRGridSortInfo
{
public string FieldName { get; set; } = "";
public bool Descending { get; set; }
}
#endregion
/// <summary>
/// GridCustomDataSource implementation that wraps AcSignalRDataSource.
/// Provides instant local filtering for previously seen filter criteria,
/// while refreshing data in background using SignalR callback pattern.
///
/// Key features:
/// - Uses AcSignalRDataSource for caching and background populate
/// - Tracks seen filter criteria - if already seen, returns local data instantly
/// - Background refresh with callback/populate pattern (no UI blocking)
/// - Full GridCustomDataSource support (filter, sort, page, group, summary)
/// </summary>
/// <typeparam name="TDataItem">Entity type implementing IId</typeparam>
/// <typeparam name="TId">ID type (int, Guid, long, etc.)</typeparam>
public class MgGridSignalRDataSource<TDataItem, TId> : GridCustomDataSource
where TDataItem : class, IId<TId>
where TId : struct
{
private readonly AcSignalRDataSource<TDataItem, TId, AcObservableCollection<TDataItem>> _innerDataSource;
private readonly AcLoggerBase? _logger;
// DevExpress CriteriaOperator to Expression converter
private readonly CriteriaToExpressionConverter _criteriaConverter = new();
// Track filter criteria that have been seen before
private readonly HashSet<string> _knownFilterCriteria = new(StringComparer.Ordinal);
// Lock for thread-safe operations
private readonly object _syncLock = new();
// Event fired when background refresh completes
public event Action? OnBackgroundRefreshCompleted;
/// <summary>
/// Creates a new MgGridSignalRDataSource wrapping an existing AcSignalRDataSource
/// </summary>
/// <param name="innerDataSource">The underlying AcSignalRDataSource that handles caching and SignalR communication</param>
/// <param name="logger">Optional logger for debugging</param>
public MgGridSignalRDataSource(
AcSignalRDataSource<TDataItem, TId, AcObservableCollection<TDataItem>> innerDataSource,
AcLoggerBase? logger = null)
{
_innerDataSource = innerDataSource ?? throw new ArgumentNullException(nameof(innerDataSource));
_logger = logger;
// Subscribe to data source events
_innerDataSource.OnDataSourceLoaded += OnInnerDataSourceLoaded;
}
/// <summary>
/// Specifies the data item type for the grid
/// </summary>
protected override Type DataItemType => typeof(TDataItem);
/// <summary>
/// Gets the inner AcSignalRDataSource for direct access if needed
/// </summary>
public AcSignalRDataSource<TDataItem, TId, AcObservableCollection<TDataItem>> InnerDataSource => _innerDataSource;
#region GridCustomDataSource Implementation
/// <summary>
/// Gets the total count of items matching the current filter.
/// If filter was seen before, returns local count instantly and refreshes in background.
/// </summary>
public override async Task<int> GetItemCountAsync(
GridCustomDataSourceCountOptions options,
CancellationToken cancellationToken)
{
var filterKey = GetFilterKey(options.FilterCriteria);
_logger?.Debug($"[MgGridSignalRDataSource] GetItemCountAsync - Filter: {filterKey}");
// If we have local data and this filter was seen before, return local count
if (_innerDataSource.Count > 0 && IsKnownFilter(filterKey))
{
var localCount = ApplyLocalFilter(_innerDataSource.ToList(), options.FilterCriteria).Count;
_logger?.Debug($"[MgGridSignalRDataSource] Returning local count: {localCount}, refreshing in background");
// Refresh in background (fire-and-forget)
_ = RefreshInBackgroundAsync(filterKey);
return localCount;
}
// First time seeing this filter - must wait for server
_logger?.Debug("[MgGridSignalRDataSource] New filter, waiting for server data");
await LoadFromServerAsync();
MarkFilterAsKnown(filterKey);
return ApplyLocalFilter(_innerDataSource.ToList(), options.FilterCriteria).Count;
}
/// <summary>
/// Gets items for the current page with filtering and sorting applied.
/// If filter was seen before, returns local data instantly and refreshes in background.
/// </summary>
public override async Task<IList> GetItemsAsync(
GridCustomDataSourceItemsOptions options,
CancellationToken cancellationToken)
{
var filterKey = GetFilterKey(options.FilterCriteria);
_logger?.Debug($"[MgGridSignalRDataSource] GetItemsAsync - Skip: {options.StartIndex}, Take: {options.Count}, Filter: {filterKey}");
// If we have local data and this filter was seen before, return local data
if (_innerDataSource.Count > 0 && IsKnownFilter(filterKey))
{
var localResult = GetLocalItems(options);
_logger?.Debug($"[MgGridSignalRDataSource] Returning {localResult.Count} local items, refreshing in background");
// Refresh in background (fire-and-forget)
_ = RefreshInBackgroundAsync(filterKey);
return localResult;
}
// First time seeing this filter - must wait for server
_logger?.Debug("[MgGridSignalRDataSource] New filter, waiting for server data");
await LoadFromServerAsync();
MarkFilterAsKnown(filterKey);
return GetLocalItems(options);
}
/// <summary>
/// Gets unique values for a column (used in filter dropdowns).
/// Always returns from local data.
/// </summary>
public override Task<object[]> GetUniqueValuesAsync(
GridCustomDataSourceUniqueValuesOptions options,
CancellationToken cancellationToken)
{
_logger?.Debug($"[MgGridSignalRDataSource] GetUniqueValuesAsync - Field: {options.FieldName}");
if (_innerDataSource.Count == 0)
return Task.FromResult(Array.Empty<object>());
try
{
var propertyInfo = typeof(TDataItem).GetProperty(options.FieldName);
if (propertyInfo == null)
return Task.FromResult(Array.Empty<object>());
var uniqueValues = _innerDataSource
.Select(item => propertyInfo.GetValue(item))
.Where(v => v != null)
.Distinct()
.Cast<object>()
.ToArray();
_logger?.Debug($"[MgGridSignalRDataSource] Found {uniqueValues.Length} unique values for {options.FieldName}");
return Task.FromResult(uniqueValues);
}
catch (Exception ex)
{
_logger?.Error($"[MgGridSignalRDataSource] GetUniqueValuesAsync failed: {ex.Message}", ex);
return Task.FromResult(Array.Empty<object>());
}
}
/// <summary>
/// Gets group information for grouped data.
/// Currently delegates to base implementation.
/// </summary>
public override async Task<IList<GridCustomDataSourceGroupInfo>> GetGroupInfoAsync(
GridCustomDataSourceGroupingOptions options,
CancellationToken cancellationToken)
{
_logger?.Debug("[MgGridSignalRDataSource] GetGroupInfoAsync");
// TODO: Implement local grouping when needed
return await base.GetGroupInfoAsync(options, cancellationToken);
}
/// <summary>
/// Gets total summary values.
/// Calculates from local data.
/// </summary>
public override Task<IList> GetTotalSummaryAsync(
GridCustomDataSourceTotalSummaryOptions options,
CancellationToken cancellationToken)
{
_logger?.Debug($"[MgGridSignalRDataSource] GetTotalSummaryAsync - Summaries: {options.SummaryInfo?.Count ?? 0}");
if (options.SummaryInfo == null || options.SummaryInfo.Count == 0 || _innerDataSource.Count == 0)
return Task.FromResult<IList>(new List<object?>());
var filteredData = ApplyLocalFilter(_innerDataSource.ToList(), options.FilterCriteria);
var summaryValues = new List<object?>();
foreach (var summaryInfo in options.SummaryInfo)
{
var value = CalculateSummary(filteredData, summaryInfo);
summaryValues.Add(value);
}
_logger?.Debug($"[MgGridSignalRDataSource] Calculated {summaryValues.Count} summary values");
return Task.FromResult<IList>(summaryValues);
}
#endregion
#region Local Data Operations
/// <summary>
/// Gets items from local cache with filter, sort, and paging applied
/// </summary>
private List<TDataItem> GetLocalItems(GridCustomDataSourceItemsOptions options)
{
var data = _innerDataSource.ToList();
// Apply filter
var filtered = ApplyLocalFilter(data, options.FilterCriteria);
// Apply sorting
var sorted = ApplyLocalSort(filtered, options.SortInfo);
// Apply paging
var paged = sorted
.Skip(options.StartIndex)
.Take(options.Count)
.ToList();
return paged;
}
/// <summary>
/// Applies CriteriaOperator filter to local data using DevExpress CriteriaToExpressionConverter
/// </summary>
private List<TDataItem> ApplyLocalFilter(List<TDataItem> data, CriteriaOperator? criteria)
{
if (criteria is null || data.Count == 0)
return data;
try
{
// Use DevExpress built-in CriteriaToExpressionConverter
var filteredData = data.AsQueryable().AppendWhere(_criteriaConverter, criteria);
return filteredData.Cast<TDataItem>().ToList();
}
catch (Exception ex)
{
_logger?.Error($"[MgGridSignalRDataSource] Local filter failed: {ex.Message}", ex);
return data;
}
}
/// <summary>
/// Applies sorting to local data
/// </summary>
private List<TDataItem> ApplyLocalSort(List<TDataItem> data, IReadOnlyList<GridCustomDataSourceSortInfo>? sortInfo)
{
if (sortInfo == null || sortInfo.Count == 0 || data.Count == 0)
return data;
try
{
IOrderedEnumerable<TDataItem>? ordered = null;
for (var i = 0; i < sortInfo.Count; i++)
{
var sort = sortInfo[i];
var propertyInfo = typeof(TDataItem).GetProperty(sort.FieldName);
if (propertyInfo == null)
continue;
Func<TDataItem, object?> keySelector = item => propertyInfo.GetValue(item);
if (i == 0)
{
ordered = sort.DescendingSortOrder
? data.OrderByDescending(keySelector)
: data.OrderBy(keySelector);
}
else if (ordered != null)
{
ordered = sort.DescendingSortOrder
? ordered.ThenByDescending(keySelector)
: ordered.ThenBy(keySelector);
}
}
return ordered?.ToList() ?? data;
}
catch (Exception ex)
{
_logger?.Error($"[MgGridSignalRDataSource] Local sort failed: {ex.Message}", ex);
return data;
}
}
/// <summary>
/// Calculates a summary value for the given data
/// </summary>
private object? CalculateSummary(List<TDataItem> data, GridCustomDataSourceSummaryInfo summaryInfo)
{
if (data.Count == 0)
return null;
try
{
var propertyInfo = typeof(TDataItem).GetProperty(summaryInfo.FieldName);
return summaryInfo.SummaryType switch
{
GridSummaryItemType.Count => data.Count,
GridSummaryItemType.Sum when propertyInfo != null =>
data.Sum(item => Convert.ToDecimal(propertyInfo.GetValue(item) ?? 0)),
GridSummaryItemType.Min when propertyInfo != null =>
data.Min(item => propertyInfo.GetValue(item)),
GridSummaryItemType.Max when propertyInfo != null =>
data.Max(item => propertyInfo.GetValue(item)),
GridSummaryItemType.Avg when propertyInfo != null =>
data.Average(item => Convert.ToDecimal(propertyInfo.GetValue(item) ?? 0)),
_ => null
};
}
catch (Exception ex)
{
_logger?.Error($"[MgGridSignalRDataSource] Summary calculation failed: {ex.Message}", ex);
return null;
}
}
#endregion
#region Filter Criteria Tracking
/// <summary>
/// Gets a unique key for the filter criteria
/// </summary>
private string GetFilterKey(CriteriaOperator? criteria)
{
if (criteria is null)
return string.Empty;
try
{
return CriteriaOperator.ToString(criteria);
}
catch
{
return string.Empty;
}
}
/// <summary>
/// Checks if this filter has been seen before
/// </summary>
private bool IsKnownFilter(string filterKey)
{
lock (_syncLock)
{
return _knownFilterCriteria.Contains(filterKey);
}
}
/// <summary>
/// Marks a filter as known (seen before)
/// </summary>
private void MarkFilterAsKnown(string filterKey)
{
lock (_syncLock)
{
_knownFilterCriteria.Add(filterKey);
}
}
/// <summary>
/// Clears the known filter cache
/// </summary>
public void ClearKnownFilters()
{
lock (_syncLock)
{
_knownFilterCriteria.Clear();
}
_logger?.Debug("[MgGridSignalRDataSource] Known filters cleared");
}
#endregion
#region Server Communication
/// <summary>
/// Loads data from server synchronously (blocking)
/// </summary>
private Task LoadFromServerAsync()
{
_logger?.Debug("[MgGridSignalRDataSource] Loading from server (sync)");
return _innerDataSource.LoadDataSource();
}
/// <summary>
/// Refreshes data in background using callback pattern (non-blocking)
/// </summary>
private Task RefreshInBackgroundAsync(string filterKey)
{
_logger?.Debug($"[MgGridSignalRDataSource] Starting background refresh for filter: {filterKey}");
// Use async callback version - this won't block
return _innerDataSource.LoadDataSourceAsync();
}
/// <summary>
/// Called when inner data source finishes loading
/// </summary>
private Task OnInnerDataSourceLoaded()
{
_logger?.Debug("[MgGridSignalRDataSource] Inner data source loaded, triggering refresh event");
OnBackgroundRefreshCompleted?.Invoke();
return Task.CompletedTask;
}
#endregion
#region Cleanup
/// <summary>
/// Invalidates all caches and clears known filters
/// </summary>
public void InvalidateCache()
{
ClearKnownFilters();
_logger?.Debug("[MgGridSignalRDataSource] Cache invalidated");
}
#endregion
}

View File

@ -0,0 +1,356 @@
using System.Linq.Expressions;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace AyCode.Blazor.Components.Services.ExpressionHelpers;
/// <summary>
/// Deserializes AcExpressionNode DTO back to Expression tree.
/// </summary>
public class AcExpressionDeserializer
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() }
};
private readonly Dictionary<int, ParameterExpression> _parameters = new();
/// <summary>
/// Deserializes JSON to Expression.
/// </summary>
public static Expression ExpressionFromJson(string json, Type? entityType = null)
{
var node = JsonSerializer.Deserialize<AcExpressionNode>(json, JsonOptions)
?? throw new ArgumentException("Invalid expression JSON", nameof(json));
var deserializer = new AcExpressionDeserializer();
return deserializer.Deserialize(node, entityType);
}
/// <summary>
/// Deserializes JSON to typed Expression.
/// </summary>
public static Expression<Func<T, TResult>> ExpressionFromJson<T, TResult>(string json)
{
var expression = ExpressionFromJson(json, typeof(T));
return (Expression<Func<T, TResult>>)expression;
}
/// <summary>
/// Deserializes AcExpressionNode to Expression.
/// </summary>
public Expression Deserialize(AcExpressionNode node, Type? entityType = null)
{
return node.NodeType switch
{
ExpressionType.Lambda => DeserializeLambda(node, entityType),
ExpressionType.Parameter => DeserializeParameter(node),
ExpressionType.Constant => DeserializeConstant(node),
ExpressionType.MemberAccess => DeserializeMemberAccess(node, entityType),
ExpressionType.Call => DeserializeMethodCall(node, entityType),
ExpressionType.Conditional => DeserializeConditional(node, entityType),
ExpressionType.New => DeserializeNew(node, entityType),
ExpressionType.MemberInit => DeserializeMemberInit(node, entityType),
ExpressionType.NewArrayInit or ExpressionType.NewArrayBounds => DeserializeNewArray(node, entityType),
ExpressionType.Invoke => DeserializeInvocation(node, entityType),
ExpressionType.TypeIs or ExpressionType.TypeAs => DeserializeTypeBinary(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
=> DeserializeUnary(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
=> DeserializeBinary(node, entityType),
_ => throw new NotSupportedException($"Expression type '{node.NodeType}' is not supported.")
};
}
#region Deserialize Methods
private LambdaExpression DeserializeLambda(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 = Deserialize(node.Body!, null);
return Expression.Lambda(body, parameters);
}
private ParameterExpression DeserializeParameter(AcExpressionNode node)
{
if (node.ParameterIndex.HasValue && _parameters.TryGetValue(node.ParameterIndex.Value, out var param))
return param;
throw new InvalidOperationException($"Parameter '{node.ParameterName}' not found.");
}
private static ConstantExpression DeserializeConstant(AcExpressionNode node)
{
var type = ResolveType(node.TypeName ?? "System.Object");
if (node.Value == null)
return Expression.Constant(null, type);
var value = JsonSerializer.Deserialize(node.Value, type, JsonOptions);
return Expression.Constant(value, type);
}
private Expression DeserializeMemberAccess(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 = Deserialize(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 DeserializeMethodCall(AcExpressionNode node, Type? entityType)
{
var arguments = node.Arguments?.Select(a => Deserialize(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 ? Deserialize(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 DeserializeBinary(AcExpressionNode node, Type? entityType)
{
var left = Deserialize(node.Left!, entityType);
var right = Deserialize(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 DeserializeUnary(AcExpressionNode node, Type? entityType)
{
var operand = Deserialize(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 DeserializeConditional(AcExpressionNode node, Type? entityType)
{
var test = Deserialize(node.Test!, entityType);
var ifTrue = Deserialize(node.IfTrue!, entityType);
var ifFalse = Deserialize(node.IfFalse!, entityType);
return Expression.Condition(test, ifTrue, ifFalse);
}
private Expression DeserializeNew(AcExpressionNode node, Type? entityType)
{
var type = ResolveType(node.TypeName!);
var args = node.ConstructorArguments?.Select(a => Deserialize(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 DeserializeMemberInit(AcExpressionNode node, Type? entityType)
{
var type = ResolveType(node.TypeName!);
var args = node.ConstructorArguments?.Select(a => Deserialize(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 => DeserializeMemberBinding(b, type, entityType)).ToList()
?? [];
return Expression.MemberInit(newExpr, bindings);
}
private MemberBinding DeserializeMemberBinding(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, Deserialize(node.Expression!, entityType)),
MemberBindingType.MemberBinding => Expression.MemberBind(member,
node.Bindings?.Select(b => DeserializeMemberBinding(b, GetMemberType(member), entityType)) ?? []),
MemberBindingType.ListBinding => Expression.ListBind(member,
node.Initializers?.Select(args => Expression.ElementInit(
GetAddMethod(GetMemberType(member)),
args.Select(a => Deserialize(a, entityType)))) ?? []),
_ => throw new NotSupportedException($"Binding type '{node.BindingType}' is not supported.")
};
}
private Expression DeserializeNewArray(AcExpressionNode node, Type? entityType)
{
var elementType = ResolveType(node.TypeName!).GetElementType()
?? throw new InvalidOperationException("Cannot determine array element type.");
var elements = node.Elements?.Select(e => Deserialize(e, entityType)).ToArray() ?? [];
return Expression.NewArrayInit(elementType, elements);
}
private Expression DeserializeInvocation(AcExpressionNode node, Type? entityType)
{
var expression = Deserialize(node.Object!, entityType);
var arguments = node.Arguments?.Select(a => Deserialize(a, entityType)).ToArray() ?? [];
return Expression.Invoke(expression, arguments);
}
private Expression DeserializeTypeBinary(AcExpressionNode node, Type? entityType)
{
var expression = Deserialize(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,238 @@
using System.Linq.Expressions;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace AyCode.Blazor.Components.Services.ExpressionHelpers;
/// <summary>
/// Helper class for serializing and deserializing Expression trees and IQueryable queries.
/// Uses visitor pattern to handle all expression types automatically.
/// </summary>
public static class AcExpressionHelper
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() }
};
#region Expression Serialization
/// <summary>
/// Serializes an Expression to AcExpressionNode DTO.
/// </summary>
public static AcExpressionNode ExpressionToNode(Expression expression)
{
var visitor = new AcExpressionSerializerVisitor();
return visitor.Convert(expression);
}
/// <summary>
/// Serializes an Expression to JSON string.
/// </summary>
public static string ExpressionToJson(Expression expression)
{
var node = ExpressionToNode(expression);
return JsonSerializer.Serialize(node, JsonOptions);
}
/// <summary>
/// Serializes a typed Expression to JSON string.
/// </summary>
public static string ExpressionToJson<TEntity, TResult>(Expression<Func<TEntity, TResult>> expression)
{
return ExpressionToJson((Expression)expression);
}
#endregion
#region Expression Deserialization
/// <summary>
/// Deserializes AcExpressionNode DTO to Expression.
/// </summary>
public static Expression ExpressionFromNode(AcExpressionNode node, Type? entityType = null)
{
var deserializer = new AcExpressionDeserializer();
return deserializer.Deserialize(node, entityType);
}
/// <summary>
/// Deserializes JSON to Expression.
/// </summary>
public static Expression ExpressionFromJson(string json, Type? entityType = null)
{
return AcExpressionDeserializer.ExpressionFromJson(json, entityType);
}
/// <summary>
/// Deserializes JSON to typed Expression.
/// </summary>
public static Expression<Func<TEntity, TResult>> ExpressionFromJson<TEntity, TResult>(string json)
{
return AcExpressionDeserializer.ExpressionFromJson<TEntity, TResult>(json);
}
#endregion
#region IQueryable Serialization
/// <summary>
/// Serializes an IQueryable's expression tree to JSON.
/// </summary>
public static string QueryToJson<T>(IQueryable<T> query)
{
return ExpressionToJson(query.Expression);
}
/// <summary>
/// Serializes an IQueryable's expression tree to AcExpressionNode.
/// </summary>
public static AcExpressionNode QueryToNode<T>(IQueryable<T> query)
{
return ExpressionToNode(query.Expression);
}
#endregion
#region IQueryable Deserialization
/// <summary>
/// Applies a serialized query expression to an IQueryable source.
/// </summary>
public static IQueryable<T> ApplyQueryFromJson<T>(IQueryable<T> source, string json)
{
var node = JsonSerializer.Deserialize<AcExpressionNode>(json, JsonOptions)
?? throw new ArgumentException("Invalid query JSON", nameof(json));
return ApplyQueryFromNode(source, node);
}
/// <summary>
/// Applies an AcExpressionNode query to an IQueryable source.
/// </summary>
public static IQueryable<T> ApplyQueryFromNode<T>(IQueryable<T> source, AcExpressionNode node)
{
// If the node is a method call (Where, OrderBy, etc.), we need to rebuild it
// with the source expression replaced
var expression = RebuildQueryExpression(source.Expression, node, typeof(T));
return source.Provider.CreateQuery<T>(expression);
}
/// <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 deserializer = new AcExpressionDeserializer();
var lambda = deserializer.Deserialize(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 deserializer = new AcExpressionDeserializer();
var arguments = new List<Expression> { currentSource };
// Skip first argument (it's the source) and deserialize the rest
if (node.Arguments?.Count > 1)
{
for (var i = 1; i < node.Arguments.Count; i++)
{
var argNode = node.Arguments[i];
if (argNode.NodeType == ExpressionType.Quote && argNode.Operand != null)
{
// Quoted lambda - unquote and deserialize
var lambda = deserializer.Deserialize(argNode.Operand, entityType);
arguments.Add(Expression.Quote(lambda));
}
else if (argNode.NodeType == ExpressionType.Lambda)
{
var lambda = deserializer.Deserialize(argNode, entityType);
arguments.Add(Expression.Quote(lambda));
}
else
{
arguments.Add(deserializer.Deserialize(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,214 @@
using System.Linq.Expressions;
using System.Text.Json.Serialization;
namespace AyCode.Blazor.Components.Services.ExpressionHelpers;
/// <summary>
/// Universal DTO representing any Expression node.
/// Recursively represents the entire Expression tree.
/// Serializable to JSON for transport over SignalR or HTTP.
/// </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.)
/// <summary>
/// Left operand for binary expressions.
/// </summary>
public AcExpressionNode? Left { get; set; }
/// <summary>
/// Right operand for binary expressions.
/// </summary>
public AcExpressionNode? Right { get; set; }
#endregion
#region Unary Expressions (Not, Convert, Negate, etc.)
/// <summary>
/// Operand for unary expressions.
/// </summary>
public AcExpressionNode? Operand { get; set; }
#endregion
#region Lambda Expressions
/// <summary>
/// Body of lambda expression.
/// </summary>
public AcExpressionNode? Body { get; set; }
/// <summary>
/// Parameter definitions for lambda expressions.
/// </summary>
public List<ParameterNode>? Parameters { get; set; }
#endregion
#region Member Access
/// <summary>
/// Member/property/field name for MemberAccess expressions.
/// </summary>
public string? MemberName { get; set; }
/// <summary>
/// Object expression for member access or instance method calls.
/// </summary>
public AcExpressionNode? Object { get; set; }
/// <summary>
/// Declaring type for static members.
/// </summary>
public string? DeclaringType { get; set; }
#endregion
#region Method Call
/// <summary>
/// Method name for Call expressions.
/// </summary>
public string? MethodName { get; set; }
/// <summary>
/// Arguments for method calls.
/// </summary>
public List<AcExpressionNode>? Arguments { get; set; }
/// <summary>
/// Generic type arguments for generic method calls.
/// </summary>
public List<string>? GenericArguments { get; set; }
#endregion
#region Constant
/// <summary>
/// Serialized constant value (JSON).
/// </summary>
public string? Value { get; set; }
#endregion
#region Parameter
/// <summary>
/// Parameter name for Parameter expressions.
/// </summary>
public string? ParameterName { get; set; }
/// <summary>
/// Parameter index (for matching parameters in lambda).
/// </summary>
public int? ParameterIndex { get; set; }
#endregion
#region Conditional (Ternary)
/// <summary>
/// Test expression for conditional expressions.
/// </summary>
public AcExpressionNode? Test { get; set; }
/// <summary>
/// IfTrue branch for conditional expressions.
/// </summary>
public AcExpressionNode? IfTrue { get; set; }
/// <summary>
/// IfFalse branch for conditional expressions.
/// </summary>
public AcExpressionNode? IfFalse { get; set; }
#endregion
#region New Expression
/// <summary>
/// Constructor arguments for New expressions.
/// </summary>
public List<AcExpressionNode>? ConstructorArguments { get; set; }
/// <summary>
/// Member bindings for MemberInit expressions.
/// </summary>
public List<MemberBindingNode>? MemberBindings { get; set; }
#endregion
#region Array/Collection
/// <summary>
/// Elements for NewArrayInit expressions.
/// </summary>
public List<AcExpressionNode>? Elements { get; set; }
#endregion
}
/// <summary>
/// Represents a parameter definition in a lambda expression.
/// </summary>
public sealed class ParameterNode
{
/// <summary>
/// Parameter name.
/// </summary>
public string Name { get; set; } = "";
/// <summary>
/// Parameter type name.
/// </summary>
public string TypeName { get; set; } = "";
/// <summary>
/// Parameter index in the lambda.
/// </summary>
public int Index { get; set; }
}
/// <summary>
/// Represents a member binding in MemberInit expressions.
/// </summary>
public sealed class MemberBindingNode
{
/// <summary>
/// The member name being bound.
/// </summary>
public string MemberName { get; set; } = "";
/// <summary>
/// The binding type (Assignment, MemberBinding, ListBinding).
/// </summary>
public MemberBindingType BindingType { get; set; }
/// <summary>
/// The expression being assigned (for Assignment bindings).
/// </summary>
public AcExpressionNode? Expression { get; set; }
/// <summary>
/// Nested bindings (for MemberMemberBinding).
/// </summary>
public List<MemberBindingNode>? Bindings { get; set; }
/// <summary>
/// Element initializers (for ListBinding).
/// </summary>
public List<List<AcExpressionNode>>? Initializers { get; set; }
}

View File

@ -0,0 +1,429 @@
using System.Linq.Expressions;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace AyCode.Blazor.Components.Services.ExpressionHelpers;
/// <summary>
/// Expression visitor that serializes an Expression tree to AcExpressionNode DTO.
/// Handles all common expression types recursively.
/// </summary>
public class AcExpressionSerializerVisitor : ExpressionVisitor
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() }
};
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>
/// Serializes an Expression to JSON string.
/// </summary>
public string ToJson(Expression expression)
{
var node = Convert(expression);
return JsonSerializer.Serialize(node, JsonOptions);
}
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(new AcExpressionNode
{
NodeType = ExpressionType.Constant,
TypeName = node.Type.FullName,
Value = SerializeValue(value)
});
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(new AcExpressionNode
{
NodeType = ExpressionType.Constant,
TypeName = node.Type.FullName,
Value = SerializeValue(node.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.FullName ?? t.Name).ToList()
: null;
_nodeStack.Push(new AcExpressionNode
{
NodeType = ExpressionType.Call,
TypeName = node.Type.FullName,
MethodName = node.Method.Name,
Object = objectNode,
Arguments = arguments,
DeclaringType = 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;
}
private static string? SerializeValue(object? value)
{
if (value == null) return null;
// Handle IQueryable source - serialize as placeholder
if (value is IQueryable)
return null;
return JsonSerializer.Serialize(value, JsonOptions);
}
#endregion
}