AyCode.Blazor/AyCode.Blazor.Components/Components/Grids/MgGridSignalRDataSource.cs

463 lines
16 KiB
C#

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