- Add nullable annotations across discovery interfaces, dynamic query models, and filter/aggregate types to eliminate CS8600-series warnings - Replace unsafe cast in DynamicQueryHandlerBase with pattern match - Add CI workflow (build --warnaserror + test on JP branch) - Add weekly security vulnerability scan workflow - Extend .gitignore with secret/credential patterns (.env, *.key, secrets/, credentials.json) Co-Authored-By: Svrnty Inc. <eng@svrnty.com>
188 lines
7.6 KiB
C#
188 lines
7.6 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics.CodeAnalysis;
|
|
using System.Globalization;
|
|
using System.Linq;
|
|
using System.Reflection;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Svrnty.CQRS.DynamicQuery.Abstractions;
|
|
using PoweredSoft.DynamicQuery;
|
|
using PoweredSoft.DynamicQuery.Core;
|
|
|
|
namespace Svrnty.CQRS.DynamicQuery;
|
|
|
|
public abstract class DynamicQueryHandlerBase<TSource, TDestination>
|
|
where TSource : class
|
|
where TDestination : class
|
|
{
|
|
private readonly IQueryHandlerAsync _queryHandlerAsync;
|
|
private readonly IEnumerable<IQueryableProvider<TSource>> _queryableProviders;
|
|
private readonly IEnumerable<IAlterQueryableService<TSource, TDestination>> _alterQueryableServices;
|
|
|
|
private readonly IEnumerable<IDynamicQueryInterceptorProvider<TSource, TDestination>>
|
|
_dynamicQueryInterceptorProviders;
|
|
|
|
private readonly IServiceProvider _serviceProvider;
|
|
|
|
public DynamicQueryHandlerBase(IQueryHandlerAsync queryHandlerAsync,
|
|
IEnumerable<IQueryableProvider<TSource>> queryableProviders,
|
|
IEnumerable<IAlterQueryableService<TSource, TDestination>> alterQueryableServices,
|
|
IEnumerable<IDynamicQueryInterceptorProvider<TSource, TDestination>> dynamicQueryInterceptorProviders,
|
|
IServiceProvider serviceProvider)
|
|
{
|
|
_queryHandlerAsync = queryHandlerAsync;
|
|
_queryableProviders = queryableProviders;
|
|
_alterQueryableServices = alterQueryableServices;
|
|
_dynamicQueryInterceptorProviders = dynamicQueryInterceptorProviders;
|
|
_serviceProvider = serviceProvider;
|
|
}
|
|
|
|
protected virtual Task<IQueryable<TSource>> GetQueryableAsync(IDynamicQuery query,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (_queryableProviders.Any())
|
|
{
|
|
// Use Last() to prefer closed generic registrations (overrides) over open generic (default)
|
|
// Registration order: open generic first, closed generic (override) last
|
|
return _queryableProviders.Last().GetQueryableAsync(query, cancellationToken);
|
|
}
|
|
|
|
throw new Exception($"You must provide a QueryableProvider<TSource> for {typeof(TSource).Name}");
|
|
}
|
|
|
|
public virtual IQueryExecutionOptions GetQueryExecutionOptions(IQueryable<TSource> source, IDynamicQuery query)
|
|
{
|
|
return new QueryExecutionOptions();
|
|
}
|
|
|
|
public virtual IEnumerable<IQueryInterceptor> GetInterceptors()
|
|
{
|
|
var types = _dynamicQueryInterceptorProviders.SelectMany(t => t.GetInterceptorsTypes()).Distinct();
|
|
foreach (var type in types)
|
|
{
|
|
if (_serviceProvider.GetService(type) is IQueryInterceptor interceptor)
|
|
yield return interceptor;
|
|
}
|
|
}
|
|
|
|
protected async Task<IQueryExecutionResult<TDestination>> ProcessQueryAsync(IDynamicQuery query,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var source = await GetQueryableAsync(query, cancellationToken);
|
|
source = await AlterSourceAsync(source, query, cancellationToken);
|
|
var options = GetQueryExecutionOptions(source, query);
|
|
var interceptors = GetInterceptors();
|
|
|
|
foreach (var interceptor in interceptors)
|
|
_queryHandlerAsync.AddInterceptor(interceptor);
|
|
|
|
var criteria = CreateCriteriaFromQuery(query);
|
|
var result =
|
|
await _queryHandlerAsync.ExecuteAsync<TSource, TDestination>(source, criteria, options, cancellationToken);
|
|
return result;
|
|
}
|
|
|
|
protected virtual async Task<IQueryable<TSource>> AlterSourceAsync(IQueryable<TSource> source, IDynamicQuery query,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
foreach (var t in _alterQueryableServices)
|
|
source = await t.AlterQueryableAsync(source, query, cancellationToken);
|
|
|
|
return source;
|
|
}
|
|
|
|
protected virtual IQueryCriteria CreateCriteriaFromQuery(IDynamicQuery query)
|
|
{
|
|
var filters = query?.GetFilters() ?? new List<IFilter>();
|
|
ConvertFilterValuesToPropertyTypes(filters);
|
|
var criteria = new QueryCriteria
|
|
{
|
|
Page = query?.GetPage(),
|
|
PageSize = query?.GetPageSize(),
|
|
Filters = filters,
|
|
Sorts = query?.GetSorts() ?? new List<ISort>(),
|
|
Groups = query?.GetGroups() ?? new List<IGroup>(),
|
|
Aggregates = query?.GetAggregates() ?? new List<IAggregate>()
|
|
};
|
|
return criteria;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Converts string filter values to the correct CLR type based on TSource property types.
|
|
/// This handles the case where transport layers (e.g. gRPC) pass all values as strings,
|
|
/// but PoweredSoft.DynamicLinq needs the actual type to build LINQ expressions.
|
|
/// </summary>
|
|
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL2087",
|
|
Justification = "TSource properties are preserved by EF Core and DynamicLinq usage")]
|
|
private static void ConvertFilterValuesToPropertyTypes(List<IFilter> filters)
|
|
{
|
|
for (var i = filters.Count - 1; i >= 0; i--)
|
|
{
|
|
var filter = filters[i];
|
|
if (filter is SimpleFilter simpleFilter)
|
|
{
|
|
if (simpleFilter.Value == null)
|
|
{
|
|
filters.RemoveAt(i);
|
|
continue;
|
|
}
|
|
|
|
if (simpleFilter.Value is string strValue && !string.IsNullOrEmpty(strValue))
|
|
{
|
|
var propertyType = ResolvePropertyType(typeof(TSource), simpleFilter.Path);
|
|
if (propertyType == null)
|
|
continue;
|
|
|
|
var targetType = Nullable.GetUnderlyingType(propertyType) ?? propertyType;
|
|
|
|
if (targetType == typeof(DateTime))
|
|
{
|
|
if (DateTime.TryParse(strValue, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal,
|
|
out var dt))
|
|
{
|
|
simpleFilter.Value = DateTime.SpecifyKind(dt, DateTimeKind.Utc);
|
|
}
|
|
}
|
|
else if (targetType == typeof(DateTimeOffset))
|
|
{
|
|
if (DateTimeOffset.TryParse(strValue, CultureInfo.InvariantCulture, DateTimeStyles.None,
|
|
out var dto))
|
|
{
|
|
simpleFilter.Value = dto;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
else if (filter is CompositeFilter compositeFilter && compositeFilter.Filters != null)
|
|
{
|
|
ConvertFilterValuesToPropertyTypes(compositeFilter.Filters);
|
|
}
|
|
}
|
|
|
|
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL2070",
|
|
Justification = "Property types are preserved by EF Core and DynamicLinq usage")]
|
|
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL2075",
|
|
Justification = "Nested property type resolution is inherently dynamic")]
|
|
static Type? ResolvePropertyType(
|
|
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type type, string? path)
|
|
{
|
|
if (string.IsNullOrEmpty(path))
|
|
return null;
|
|
|
|
var currentType = type;
|
|
foreach (var part in path.Split('.'))
|
|
{
|
|
var property = currentType.GetProperty(part,
|
|
BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
|
|
if (property == null)
|
|
return null;
|
|
currentType = property.PropertyType;
|
|
}
|
|
|
|
return currentType;
|
|
}
|
|
}
|
|
}
|
|
|