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:
parent
500e39a514
commit
b6f51bc2a1
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue