From b6f51bc2a178099c7f1b799fdcd5946d8a5995f7 Mon Sep 17 00:00:00 2001 From: Loretta Date: Tue, 30 Dec 2025 19:29:50 +0100 Subject: [PATCH] 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 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. --- .../AyCode.Blazor.Components.Tests.csproj | 2 +- .../Grids/MgGridSignalRDataSource.cs | 462 ++++++++++++++++++ .../AcExpressionDeserializer.cs | 356 ++++++++++++++ .../ExpressionHelpers/AcExpressionHelper.cs | 238 +++++++++ .../ExpressionHelpers/AcExpressionNode.cs | 214 ++++++++ .../AcExpressionSerializerVisitor.cs | 429 ++++++++++++++++ 6 files changed, 1700 insertions(+), 1 deletion(-) create mode 100644 AyCode.Blazor.Components/Components/Grids/MgGridSignalRDataSource.cs create mode 100644 AyCode.Blazor.Components/Services/ExpressionHelpers/AcExpressionDeserializer.cs create mode 100644 AyCode.Blazor.Components/Services/ExpressionHelpers/AcExpressionHelper.cs create mode 100644 AyCode.Blazor.Components/Services/ExpressionHelpers/AcExpressionNode.cs create mode 100644 AyCode.Blazor.Components/Services/ExpressionHelpers/AcExpressionSerializerVisitor.cs diff --git a/AyCode.Blazor.Components.Tests/AyCode.Blazor.Components.Tests.csproj b/AyCode.Blazor.Components.Tests/AyCode.Blazor.Components.Tests.csproj index 7d73d4c..11cd5a9 100644 --- a/AyCode.Blazor.Components.Tests/AyCode.Blazor.Components.Tests.csproj +++ b/AyCode.Blazor.Components.Tests/AyCode.Blazor.Components.Tests.csproj @@ -8,7 +8,7 @@ - + diff --git a/AyCode.Blazor.Components/Components/Grids/MgGridSignalRDataSource.cs b/AyCode.Blazor.Components/Components/Grids/MgGridSignalRDataSource.cs new file mode 100644 index 0000000..ae92efe --- /dev/null +++ b/AyCode.Blazor.Components/Components/Grids/MgGridSignalRDataSource.cs @@ -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 + +/// +/// Sorting information for a single field +/// +public class SignalRGridSortInfo +{ + public string FieldName { get; set; } = ""; + public bool Descending { get; set; } +} + +#endregion + +/// +/// 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) +/// +/// Entity type implementing IId +/// ID type (int, Guid, long, etc.) +public class MgGridSignalRDataSource : GridCustomDataSource + where TDataItem : class, IId + where TId : struct +{ + private readonly AcSignalRDataSource> _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 _knownFilterCriteria = new(StringComparer.Ordinal); + + // Lock for thread-safe operations + private readonly object _syncLock = new(); + + // Event fired when background refresh completes + public event Action? OnBackgroundRefreshCompleted; + + /// + /// Creates a new MgGridSignalRDataSource wrapping an existing AcSignalRDataSource + /// + /// The underlying AcSignalRDataSource that handles caching and SignalR communication + /// Optional logger for debugging + public MgGridSignalRDataSource( + AcSignalRDataSource> innerDataSource, + AcLoggerBase? logger = null) + { + _innerDataSource = innerDataSource ?? throw new ArgumentNullException(nameof(innerDataSource)); + _logger = logger; + + // Subscribe to data source events + _innerDataSource.OnDataSourceLoaded += OnInnerDataSourceLoaded; + } + + /// + /// Specifies the data item type for the grid + /// + protected override Type DataItemType => typeof(TDataItem); + + /// + /// Gets the inner AcSignalRDataSource for direct access if needed + /// + public AcSignalRDataSource> InnerDataSource => _innerDataSource; + + #region GridCustomDataSource Implementation + + /// + /// Gets the total count of items matching the current filter. + /// If filter was seen before, returns local count instantly and refreshes in background. + /// + public override async Task 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; + } + + /// + /// Gets items for the current page with filtering and sorting applied. + /// If filter was seen before, returns local data instantly and refreshes in background. + /// + public override async Task 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); + } + + /// + /// Gets unique values for a column (used in filter dropdowns). + /// Always returns from local data. + /// + public override Task GetUniqueValuesAsync( + GridCustomDataSourceUniqueValuesOptions options, + CancellationToken cancellationToken) + { + _logger?.Debug($"[MgGridSignalRDataSource] GetUniqueValuesAsync - Field: {options.FieldName}"); + + if (_innerDataSource.Count == 0) + return Task.FromResult(Array.Empty()); + + try + { + var propertyInfo = typeof(TDataItem).GetProperty(options.FieldName); + if (propertyInfo == null) + return Task.FromResult(Array.Empty()); + + var uniqueValues = _innerDataSource + .Select(item => propertyInfo.GetValue(item)) + .Where(v => v != null) + .Distinct() + .Cast() + .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()); + } + } + + /// + /// Gets group information for grouped data. + /// Currently delegates to base implementation. + /// + public override async Task> GetGroupInfoAsync( + GridCustomDataSourceGroupingOptions options, + CancellationToken cancellationToken) + { + _logger?.Debug("[MgGridSignalRDataSource] GetGroupInfoAsync"); + + // TODO: Implement local grouping when needed + return await base.GetGroupInfoAsync(options, cancellationToken); + } + + /// + /// Gets total summary values. + /// Calculates from local data. + /// + public override Task 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(new List()); + + var filteredData = ApplyLocalFilter(_innerDataSource.ToList(), options.FilterCriteria); + var summaryValues = new List(); + + 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(summaryValues); + } + + #endregion + + #region Local Data Operations + + /// + /// Gets items from local cache with filter, sort, and paging applied + /// + private List 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; + } + + /// + /// Applies CriteriaOperator filter to local data using DevExpress CriteriaToExpressionConverter + /// + private List ApplyLocalFilter(List 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().ToList(); + } + catch (Exception ex) + { + _logger?.Error($"[MgGridSignalRDataSource] Local filter failed: {ex.Message}", ex); + return data; + } + } + + /// + /// Applies sorting to local data + /// + private List ApplyLocalSort(List data, IReadOnlyList? sortInfo) + { + if (sortInfo == null || sortInfo.Count == 0 || data.Count == 0) + return data; + + try + { + IOrderedEnumerable? 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 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; + } + } + + /// + /// Calculates a summary value for the given data + /// + private object? CalculateSummary(List 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 + + /// + /// Gets a unique key for the filter criteria + /// + private string GetFilterKey(CriteriaOperator? criteria) + { + if (criteria is null) + return string.Empty; + + try + { + return CriteriaOperator.ToString(criteria); + } + catch + { + return string.Empty; + } + } + + /// + /// Checks if this filter has been seen before + /// + private bool IsKnownFilter(string filterKey) + { + lock (_syncLock) + { + return _knownFilterCriteria.Contains(filterKey); + } + } + + /// + /// Marks a filter as known (seen before) + /// + private void MarkFilterAsKnown(string filterKey) + { + lock (_syncLock) + { + _knownFilterCriteria.Add(filterKey); + } + } + + /// + /// Clears the known filter cache + /// + public void ClearKnownFilters() + { + lock (_syncLock) + { + _knownFilterCriteria.Clear(); + } + _logger?.Debug("[MgGridSignalRDataSource] Known filters cleared"); + } + + #endregion + + #region Server Communication + + /// + /// Loads data from server synchronously (blocking) + /// + private Task LoadFromServerAsync() + { + _logger?.Debug("[MgGridSignalRDataSource] Loading from server (sync)"); + return _innerDataSource.LoadDataSource(); + } + + /// + /// Refreshes data in background using callback pattern (non-blocking) + /// + 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(); + } + + /// + /// Called when inner data source finishes loading + /// + private Task OnInnerDataSourceLoaded() + { + _logger?.Debug("[MgGridSignalRDataSource] Inner data source loaded, triggering refresh event"); + OnBackgroundRefreshCompleted?.Invoke(); + return Task.CompletedTask; + } + + #endregion + + #region Cleanup + + /// + /// Invalidates all caches and clears known filters + /// + public void InvalidateCache() + { + ClearKnownFilters(); + _logger?.Debug("[MgGridSignalRDataSource] Cache invalidated"); + } + + #endregion +} diff --git a/AyCode.Blazor.Components/Services/ExpressionHelpers/AcExpressionDeserializer.cs b/AyCode.Blazor.Components/Services/ExpressionHelpers/AcExpressionDeserializer.cs new file mode 100644 index 0000000..e0dda2b --- /dev/null +++ b/AyCode.Blazor.Components/Services/ExpressionHelpers/AcExpressionDeserializer.cs @@ -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; + +/// +/// Deserializes AcExpressionNode DTO back to Expression tree. +/// +public class AcExpressionDeserializer +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter() } + }; + + private readonly Dictionary _parameters = new(); + + /// + /// Deserializes JSON to Expression. + /// + public static Expression ExpressionFromJson(string json, Type? entityType = null) + { + var node = JsonSerializer.Deserialize(json, JsonOptions) + ?? throw new ArgumentException("Invalid expression JSON", nameof(json)); + + var deserializer = new AcExpressionDeserializer(); + return deserializer.Deserialize(node, entityType); + } + + /// + /// Deserializes JSON to typed Expression. + /// + public static Expression> ExpressionFromJson(string json) + { + var expression = ExpressionFromJson(json, typeof(T)); + return (Expression>)expression; + } + + /// + /// Deserializes AcExpressionNode to Expression. + /// + 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(); + 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 +} diff --git a/AyCode.Blazor.Components/Services/ExpressionHelpers/AcExpressionHelper.cs b/AyCode.Blazor.Components/Services/ExpressionHelpers/AcExpressionHelper.cs new file mode 100644 index 0000000..4f033da --- /dev/null +++ b/AyCode.Blazor.Components/Services/ExpressionHelpers/AcExpressionHelper.cs @@ -0,0 +1,238 @@ +using System.Linq.Expressions; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AyCode.Blazor.Components.Services.ExpressionHelpers; + +/// +/// Helper class for serializing and deserializing Expression trees and IQueryable queries. +/// Uses visitor pattern to handle all expression types automatically. +/// +public static class AcExpressionHelper +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter() } + }; + + #region Expression Serialization + + /// + /// Serializes an Expression to AcExpressionNode DTO. + /// + public static AcExpressionNode ExpressionToNode(Expression expression) + { + var visitor = new AcExpressionSerializerVisitor(); + return visitor.Convert(expression); + } + + /// + /// Serializes an Expression to JSON string. + /// + public static string ExpressionToJson(Expression expression) + { + var node = ExpressionToNode(expression); + return JsonSerializer.Serialize(node, JsonOptions); + } + + /// + /// Serializes a typed Expression to JSON string. + /// + public static string ExpressionToJson(Expression> expression) + { + return ExpressionToJson((Expression)expression); + } + + #endregion + + #region Expression Deserialization + + /// + /// Deserializes AcExpressionNode DTO to Expression. + /// + public static Expression ExpressionFromNode(AcExpressionNode node, Type? entityType = null) + { + var deserializer = new AcExpressionDeserializer(); + return deserializer.Deserialize(node, entityType); + } + + /// + /// Deserializes JSON to Expression. + /// + public static Expression ExpressionFromJson(string json, Type? entityType = null) + { + return AcExpressionDeserializer.ExpressionFromJson(json, entityType); + } + + /// + /// Deserializes JSON to typed Expression. + /// + public static Expression> ExpressionFromJson(string json) + { + return AcExpressionDeserializer.ExpressionFromJson(json); + } + + #endregion + + #region IQueryable Serialization + + /// + /// Serializes an IQueryable's expression tree to JSON. + /// + public static string QueryToJson(IQueryable query) + { + return ExpressionToJson(query.Expression); + } + + /// + /// Serializes an IQueryable's expression tree to AcExpressionNode. + /// + public static AcExpressionNode QueryToNode(IQueryable query) + { + return ExpressionToNode(query.Expression); + } + + #endregion + + #region IQueryable Deserialization + + /// + /// Applies a serialized query expression to an IQueryable source. + /// + public static IQueryable ApplyQueryFromJson(IQueryable source, string json) + { + var node = JsonSerializer.Deserialize(json, JsonOptions) + ?? throw new ArgumentException("Invalid query JSON", nameof(json)); + + return ApplyQueryFromNode(source, node); + } + + /// + /// Applies an AcExpressionNode query to an IQueryable source. + /// + public static IQueryable ApplyQueryFromNode(IQueryable 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(expression); + } + + /// + /// Rebuilds a query expression, replacing the source with the provided expression. + /// + private static Expression RebuildQueryExpression(Expression sourceExpression, AcExpressionNode node, Type entityType) + { + if (node is { NodeType: ExpressionType.Call, MethodName: not null }) + { + return RebuildMethodCallChain(sourceExpression, node, entityType); + } + + // If it's just a lambda (filter expression), wrap it in a Where call + if (node.NodeType == ExpressionType.Lambda) + { + var 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."); + } + + /// + /// Rebuilds a chain of method calls (Where, OrderBy, Skip, Take, etc.) + /// + private static Expression RebuildMethodCallChain(Expression sourceExpression, AcExpressionNode node, Type entityType) + { + // First, process the inner expression (the source of this method call) + Expression currentSource; + + if (node.Arguments?.Count > 0 && node.Arguments[0].NodeType == ExpressionType.Call) + { + // Recursive: rebuild the inner chain first + currentSource = RebuildMethodCallChain(sourceExpression, node.Arguments[0], entityType); + } + else + { + // Base case: use the provided source + currentSource = sourceExpression; + } + + // Now apply this method call + var methodName = node.MethodName!; + var declaringType = node.DeclaringType != null ? Type.GetType(node.DeclaringType) : typeof(Queryable); + + // Find the method + var method = FindQueryableMethod(declaringType!, methodName, node.GenericArguments, node.Arguments?.Count ?? 1); + + if (method == null) + throw new InvalidOperationException($"Method '{methodName}' not found."); + + // Apply generic type arguments + if (method.IsGenericMethodDefinition && node.GenericArguments?.Count > 0) + { + var genericTypes = node.GenericArguments.Select(t => Type.GetType(t) ?? entityType).ToArray(); + method = method.MakeGenericMethod(genericTypes); + } + + // Build arguments + var deserializer = new AcExpressionDeserializer(); + var arguments = new List { 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? genericArgs, int argCount) + { + return declaringType.GetMethods() + .Where(m => m.Name == methodName) + .FirstOrDefault(m => + { + var parameters = m.GetParameters(); + if (parameters.Length != argCount) return false; + + // Check if generic argument count matches + if (m.IsGenericMethodDefinition) + { + var genericCount = genericArgs?.Count ?? 1; + if (m.GetGenericArguments().Length != genericCount) return false; + } + + return true; + }); + } + + #endregion +} diff --git a/AyCode.Blazor.Components/Services/ExpressionHelpers/AcExpressionNode.cs b/AyCode.Blazor.Components/Services/ExpressionHelpers/AcExpressionNode.cs new file mode 100644 index 0000000..b105c64 --- /dev/null +++ b/AyCode.Blazor.Components/Services/ExpressionHelpers/AcExpressionNode.cs @@ -0,0 +1,214 @@ +using System.Linq.Expressions; +using System.Text.Json.Serialization; + +namespace AyCode.Blazor.Components.Services.ExpressionHelpers; + +/// +/// Universal DTO representing any Expression node. +/// Recursively represents the entire Expression tree. +/// Serializable to JSON for transport over SignalR or HTTP. +/// +public sealed class AcExpressionNode +{ + /// + /// The expression node type (Add, Equal, Call, MemberAccess, Lambda, etc.) + /// + public ExpressionType NodeType { get; set; } + + /// + /// The CLR type name of this expression's result. + /// + public string? TypeName { get; set; } + + #region Binary Expressions (Add, Equal, AndAlso, OrElse, etc.) + + /// + /// Left operand for binary expressions. + /// + public AcExpressionNode? Left { get; set; } + + /// + /// Right operand for binary expressions. + /// + public AcExpressionNode? Right { get; set; } + + #endregion + + #region Unary Expressions (Not, Convert, Negate, etc.) + + /// + /// Operand for unary expressions. + /// + public AcExpressionNode? Operand { get; set; } + + #endregion + + #region Lambda Expressions + + /// + /// Body of lambda expression. + /// + public AcExpressionNode? Body { get; set; } + + /// + /// Parameter definitions for lambda expressions. + /// + public List? Parameters { get; set; } + + #endregion + + #region Member Access + + /// + /// Member/property/field name for MemberAccess expressions. + /// + public string? MemberName { get; set; } + + /// + /// Object expression for member access or instance method calls. + /// + public AcExpressionNode? Object { get; set; } + + /// + /// Declaring type for static members. + /// + public string? DeclaringType { get; set; } + + #endregion + + #region Method Call + + /// + /// Method name for Call expressions. + /// + public string? MethodName { get; set; } + + /// + /// Arguments for method calls. + /// + public List? Arguments { get; set; } + + /// + /// Generic type arguments for generic method calls. + /// + public List? GenericArguments { get; set; } + + #endregion + + #region Constant + + /// + /// Serialized constant value (JSON). + /// + public string? Value { get; set; } + + #endregion + + #region Parameter + + /// + /// Parameter name for Parameter expressions. + /// + public string? ParameterName { get; set; } + + /// + /// Parameter index (for matching parameters in lambda). + /// + public int? ParameterIndex { get; set; } + + #endregion + + #region Conditional (Ternary) + + /// + /// Test expression for conditional expressions. + /// + public AcExpressionNode? Test { get; set; } + + /// + /// IfTrue branch for conditional expressions. + /// + public AcExpressionNode? IfTrue { get; set; } + + /// + /// IfFalse branch for conditional expressions. + /// + public AcExpressionNode? IfFalse { get; set; } + + #endregion + + #region New Expression + + /// + /// Constructor arguments for New expressions. + /// + public List? ConstructorArguments { get; set; } + + /// + /// Member bindings for MemberInit expressions. + /// + public List? MemberBindings { get; set; } + + #endregion + + #region Array/Collection + + /// + /// Elements for NewArrayInit expressions. + /// + public List? Elements { get; set; } + + #endregion +} + +/// +/// Represents a parameter definition in a lambda expression. +/// +public sealed class ParameterNode +{ + /// + /// Parameter name. + /// + public string Name { get; set; } = ""; + + /// + /// Parameter type name. + /// + public string TypeName { get; set; } = ""; + + /// + /// Parameter index in the lambda. + /// + public int Index { get; set; } +} + +/// +/// Represents a member binding in MemberInit expressions. +/// +public sealed class MemberBindingNode +{ + /// + /// The member name being bound. + /// + public string MemberName { get; set; } = ""; + + /// + /// The binding type (Assignment, MemberBinding, ListBinding). + /// + public MemberBindingType BindingType { get; set; } + + /// + /// The expression being assigned (for Assignment bindings). + /// + public AcExpressionNode? Expression { get; set; } + + /// + /// Nested bindings (for MemberMemberBinding). + /// + public List? Bindings { get; set; } + + /// + /// Element initializers (for ListBinding). + /// + public List>? Initializers { get; set; } +} diff --git a/AyCode.Blazor.Components/Services/ExpressionHelpers/AcExpressionSerializerVisitor.cs b/AyCode.Blazor.Components/Services/ExpressionHelpers/AcExpressionSerializerVisitor.cs new file mode 100644 index 0000000..1774295 --- /dev/null +++ b/AyCode.Blazor.Components/Services/ExpressionHelpers/AcExpressionSerializerVisitor.cs @@ -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; + +/// +/// Expression visitor that serializes an Expression tree to AcExpressionNode DTO. +/// Handles all common expression types recursively. +/// +public class AcExpressionSerializerVisitor : ExpressionVisitor +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter() } + }; + + private readonly Dictionary _parameterIndexes = new(); + private int _nextParameterIndex; + + // Stack to collect converted nodes + private readonly Stack _nodeStack = new(); + + /// + /// Converts an Expression to an AcExpressionNode DTO. + /// + public AcExpressionNode Convert(Expression expression) + { + _nodeStack.Clear(); + _parameterIndexes.Clear(); + _nextParameterIndex = 0; + + VisitAndConvert(expression); + + return _nodeStack.Count != 1 ? throw new InvalidOperationException($"Expected 1 node on stack, found {_nodeStack.Count}") : _nodeStack.Pop(); + } + + /// + /// Serializes an Expression to JSON string. + /// + 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(Expression node) + { + // Register parameters with indexes + var parameters = new List(); + foreach (var param in node.Parameters) + { + var index = _nextParameterIndex++; + _parameterIndexes[param] = index; + parameters.Add(new ParameterNode + { + Name = param.Name ?? $"p{index}", + TypeName = param.Type.FullName ?? param.Type.Name, + Index = index + }); + } + + VisitAndConvert(node.Body); + var body = _nodeStack.Pop(); + + _nodeStack.Push(new AcExpressionNode + { + NodeType = ExpressionType.Lambda, + TypeName = node.Type.FullName, + Body = body, + Parameters = parameters + }); + + return node; + } + + protected override Expression VisitParameter(ParameterExpression node) + { + var index = _parameterIndexes.GetValueOrDefault(node, -1); + + _nodeStack.Push(new AcExpressionNode + { + NodeType = ExpressionType.Parameter, + TypeName = node.Type.FullName, + ParameterName = node.Name, + ParameterIndex = index + }); + + return node; + } + + protected override Expression VisitMember(MemberExpression node) + { + // Check if this is a closure variable access (captured variable) + if (IsClosureAccess(node)) + { + var value = EvaluateClosureValue(node); + _nodeStack.Push(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(); + 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(); + foreach (var arg in node.Arguments) + { + VisitAndConvert(arg); + arguments.Add(_nodeStack.Pop()); + } + + _nodeStack.Push(new AcExpressionNode + { + NodeType = ExpressionType.New, + TypeName = node.Type.FullName, + ConstructorArguments = arguments + }); + + return node; + } + + protected override Expression VisitMemberInit(MemberInitExpression node) + { + var arguments = new List(); + foreach (var arg in node.NewExpression.Arguments) + { + VisitAndConvert(arg); + arguments.Add(_nodeStack.Pop()); + } + + _nodeStack.Push(new AcExpressionNode + { + NodeType = ExpressionType.MemberInit, + TypeName = node.Type.FullName, + ConstructorArguments = arguments, + MemberBindings = node.Bindings.Select(ConvertMemberBinding).ToList() + }); + + return node; + } + + protected override Expression VisitNewArray(NewArrayExpression node) + { + var elements = new List(); + foreach (var expr in node.Expressions) + { + VisitAndConvert(expr); + elements.Add(_nodeStack.Pop()); + } + + _nodeStack.Push(new AcExpressionNode + { + NodeType = node.NodeType, + TypeName = node.Type.FullName, + Elements = elements + }); + + return node; + } + + protected override Expression VisitTypeBinary(TypeBinaryExpression node) + { + VisitAndConvert(node.Expression); + var operand = _nodeStack.Pop(); + + _nodeStack.Push(new AcExpressionNode + { + NodeType = node.NodeType, + TypeName = node.TypeOperand.FullName, + Operand = operand + }); + + return node; + } + + protected override Expression VisitInvocation(InvocationExpression node) + { + VisitAndConvert(node.Expression); + var objectNode = _nodeStack.Pop(); + + var arguments = new List(); + foreach (var arg in node.Arguments) + { + VisitAndConvert(arg); + arguments.Add(_nodeStack.Pop()); + } + + _nodeStack.Push(new AcExpressionNode + { + NodeType = ExpressionType.Invoke, + TypeName = node.Type.FullName, + Object = objectNode, + Arguments = arguments + }); + + return node; + } + + #region Helper Methods + + private MemberBindingNode ConvertMemberBinding(MemberBinding binding) + { + return binding switch + { + MemberAssignment assignment => ConvertMemberAssignment(assignment), + MemberMemberBinding memberBinding => new MemberBindingNode + { + MemberName = memberBinding.Member.Name, + BindingType = MemberBindingType.MemberBinding, + Bindings = memberBinding.Bindings.Select(ConvertMemberBinding).ToList() + }, + MemberListBinding listBinding => new MemberBindingNode + { + MemberName = listBinding.Member.Name, + BindingType = MemberBindingType.ListBinding, + Initializers = listBinding.Initializers + .Select(i => i.Arguments.Select(ConvertArgument).ToList()) + .ToList() + }, + _ => throw new NotSupportedException($"Member binding type '{binding.BindingType}' is not supported.") + }; + } + + private MemberBindingNode ConvertMemberAssignment(MemberAssignment assignment) + { + VisitAndConvert(assignment.Expression); + var expr = _nodeStack.Pop(); + + return new MemberBindingNode + { + MemberName = assignment.Member.Name, + BindingType = MemberBindingType.Assignment, + Expression = expr + }; + } + + private AcExpressionNode ConvertArgument(Expression expression) + { + VisitAndConvert(expression); + return _nodeStack.Pop(); + } + + private static bool IsClosureAccess(MemberExpression node) + { + return node.Expression switch + { + ConstantExpression => true, + MemberExpression nested => IsClosureAccess(nested), + _ => false + }; + } + + private static object? EvaluateClosureValue(MemberExpression node) + { + var objectStack = new Stack(); + Expression? current = node; + + while (current is MemberExpression me) + { + objectStack.Push(me); + current = me.Expression; + } + + if (current is not ConstantExpression constant) + throw new InvalidOperationException("Expected constant at root of closure access."); + + object? value = constant.Value; + + while (objectStack.Count > 0) + { + var me = objectStack.Pop(); + value = me.Member switch + { + FieldInfo fi => fi.GetValue(value), + PropertyInfo pi => pi.GetValue(value), + _ => throw new InvalidOperationException($"Unsupported member type: {me.Member.GetType()}") + }; + } + + return value; + } + + 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 +}