more flex and filters
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
using System.Collections;
|
||||
using System.Globalization;
|
||||
using System.Reflection;
|
||||
using System.Text.RegularExpressions;
|
||||
using IdokladSdk.Clients;
|
||||
using IdokladSdk.Requests.Core;
|
||||
using IdokladSdk.Requests.Core.Modifiers.Filters.Common;
|
||||
using IdokladSdk.Requests.Core.Modifiers.Sort.Common;
|
||||
using IdokladSdk.Response;
|
||||
|
||||
namespace Idoklad.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Translates iDoklad-style <c>filter</c>/<c>sort</c> query strings into the SDK's strongly-typed
|
||||
/// <see cref="BaseListCore{TList,TClient,TGetModel,TFilter,TSort}.Filter"/> /
|
||||
/// <see cref="BaseListCore{TList,TClient,TGetModel,TFilter,TSort}.Sort"/> modifiers, so the REST
|
||||
/// list endpoints expose the full server-side filtering the SDK supports (operators
|
||||
/// eq/neq/gt/gte/lt/lte/ct/nct, AND/OR combining, and multi-column sort) instead of only paging.
|
||||
///
|
||||
/// <para><b>Filter format</b> (identical to the public iDoklad API): one or more
|
||||
/// <c>(Property~operator~value)</c> conditions joined by <c>,</c> — e.g.
|
||||
/// <c>(DateOfTaxing~gte~2024-01-01),(IsPaid~eq~false)</c>. Parentheses are optional for a single
|
||||
/// condition. The <c>filtertype</c> query parameter (<c>and</c> | <c>or</c>, default <c>and</c>)
|
||||
/// decides how multiple conditions combine.</para>
|
||||
///
|
||||
/// <para><b>Operators:</b> <c>eq</c> (=), <c>neq</c> (≠), <c>gt</c> (>), <c>gte</c> (≥),
|
||||
/// <c>lt</c> (<), <c>lte</c> (≤), <c>ct</c> (contains), <c>nct</c> (not contains). The available
|
||||
/// operators per field follow the SDK filter type (e.g. dates support the compare operators,
|
||||
/// text fields support contains).</para>
|
||||
///
|
||||
/// <para><b>Sort format:</b> <c>Property~asc|desc</c>, multiple joined by <c>,</c> — e.g.
|
||||
/// <c>DateOfIssue~desc,Id~asc</c>. Direction defaults to ascending.</para>
|
||||
/// </summary>
|
||||
public static class ListModifiers
|
||||
{
|
||||
private static readonly Regex GroupRegex = new(@"\(([^()]*)\)", RegexOptions.Compiled);
|
||||
|
||||
private static readonly Dictionary<string, string> OperatorMethods = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["eq"] = "IsEqual",
|
||||
["neq"] = "IsNotEqual",
|
||||
["gt"] = "IsGreaterThan",
|
||||
["gte"] = "IsGreaterThanOrEqual",
|
||||
["lt"] = "IsLowerThan",
|
||||
["lte"] = "IsLowerThanOrEqual",
|
||||
["ct"] = "Contains",
|
||||
["nct"] = "NotContains",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Applies paging plus optional <paramref name="filter"/>/<paramref name="sort"/> to a list
|
||||
/// request and returns the unwrapped page. Shared by every list endpoint.
|
||||
/// </summary>
|
||||
public static async Task<Page<TGetModel>> GetPageAsync<TList, TClient, TGetModel, TFilter, TSort>(
|
||||
this BaseList<TList, TClient, TGetModel, TFilter, TSort> list,
|
||||
int page,
|
||||
int pageSize,
|
||||
string? filter,
|
||||
string? filterType,
|
||||
string? sort,
|
||||
CancellationToken ct)
|
||||
where TList : BaseList<TList, TClient, TGetModel, TFilter, TSort>
|
||||
where TClient : BaseClient
|
||||
where TFilter : new()
|
||||
where TSort : new()
|
||||
where TGetModel : new()
|
||||
{
|
||||
BaseList<TList, TClient, TGetModel, TFilter, TSort> built = list;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter))
|
||||
{
|
||||
var useOr = IsOr(filterType);
|
||||
built = built.Filter(f => BuildFilter(f!, filter, useOr));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(sort))
|
||||
{
|
||||
built = built.Sort(BuildSort<TSort>(sort));
|
||||
}
|
||||
|
||||
return (await built.Page(page).PageSize(pageSize).GetAsync(ct)).Unwrap();
|
||||
}
|
||||
|
||||
/// <summary>True when the <c>filtertype</c> query value requests OR combining (default AND).</summary>
|
||||
public static bool IsOr(string? filterType)
|
||||
=> string.Equals(filterType, "or", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Builds a combined <see cref="FilterExpressionBase"/> from an iDoklad-style filter string
|
||||
/// against the SDK filter object <paramref name="filter"/> (passed in by the SDK at call time).
|
||||
/// Conditions are combined with OR when <paramref name="useOr"/> is true, otherwise AND.
|
||||
/// </summary>
|
||||
public static FilterExpressionBase BuildFilter(object filter, string filterString, bool useOr)
|
||||
{
|
||||
FilterExpressionBase? combined = null;
|
||||
|
||||
foreach (var group in ExtractConditions(filterString))
|
||||
{
|
||||
var expression = BuildCondition(filter, group);
|
||||
combined = combined is null
|
||||
? expression
|
||||
: (useOr ? combined | expression : combined & expression);
|
||||
}
|
||||
|
||||
if (combined is null)
|
||||
{
|
||||
throw new ArgumentException($"Filtr '{filterString}' neobsahuje žádnou platnou podmínku.");
|
||||
}
|
||||
|
||||
return combined;
|
||||
}
|
||||
|
||||
/// <summary>Builds the SDK sort selectors from a <c>Field~asc|desc,...</c> string.</summary>
|
||||
public static Func<TSort, SortExpression>[] BuildSort<TSort>(string sort)
|
||||
{
|
||||
var parts = sort.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var selectors = new List<Func<TSort, SortExpression>>();
|
||||
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var segments = part.Split('~', StringSplitOptions.TrimEntries);
|
||||
var field = segments[0];
|
||||
var descending = segments.Length > 1 && segments[1].Equals("desc", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
selectors.Add(sortObj =>
|
||||
{
|
||||
var property = typeof(TSort).GetProperty(field, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase)
|
||||
?? throw new ArgumentException($"Řazení podle pole '{field}' není u této agendy podporováno.");
|
||||
var item = (SortItem)property.GetValue(sortObj)!;
|
||||
return descending ? item.Desc() : item.Asc();
|
||||
});
|
||||
}
|
||||
|
||||
return selectors.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Splits the filter string into individual <c>Name~op~value</c> conditions.</summary>
|
||||
private static IEnumerable<string> ExtractConditions(string filterString)
|
||||
{
|
||||
filterString = filterString.Trim();
|
||||
|
||||
if (filterString.Contains('('))
|
||||
{
|
||||
return GroupRegex.Matches(filterString)
|
||||
.Select(m => m.Groups[1].Value.Trim())
|
||||
.Where(s => s.Length > 0);
|
||||
}
|
||||
|
||||
return filterString.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
}
|
||||
|
||||
private static FilterExpressionBase BuildCondition(object filter, string condition)
|
||||
{
|
||||
var firstTilde = condition.IndexOf('~');
|
||||
var secondTilde = firstTilde < 0 ? -1 : condition.IndexOf('~', firstTilde + 1);
|
||||
if (firstTilde < 0 || secondTilde < 0)
|
||||
{
|
||||
throw new ArgumentException($"Neplatná filtrační podmínka '{condition}'. Očekává se tvar 'Pole~operátor~hodnota'.");
|
||||
}
|
||||
|
||||
var name = condition[..firstTilde].Trim();
|
||||
var op = condition[(firstTilde + 1)..secondTilde].Trim();
|
||||
var rawValue = condition[(secondTilde + 1)..].Trim();
|
||||
|
||||
if (!OperatorMethods.TryGetValue(op, out var methodName))
|
||||
{
|
||||
throw new ArgumentException($"Neznámý filtrační operátor '{op}'. Povolené: eq, neq, gt, gte, lt, lte, ct, nct.");
|
||||
}
|
||||
|
||||
var property = filter.GetType().GetProperty(name, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase)
|
||||
?? throw new ArgumentException($"Filtrování podle pole '{name}' není u této agendy podporováno.");
|
||||
|
||||
var item = property.GetValue(filter)
|
||||
?? throw new ArgumentException($"Filtrační pole '{name}' není dostupné.");
|
||||
|
||||
var method = FindOperatorMethod(item.GetType(), methodName)
|
||||
?? throw new ArgumentException($"Operátor '{op}' není pro pole '{name}' podporován.");
|
||||
|
||||
var targetType = method.GetParameters()[0].ParameterType;
|
||||
var value = ConvertValue(rawValue, targetType, name);
|
||||
|
||||
try
|
||||
{
|
||||
return (FilterExpressionBase)method.Invoke(item, new[] { value })!;
|
||||
}
|
||||
catch (TargetInvocationException ex) when (ex.InnerException is not null)
|
||||
{
|
||||
throw new ArgumentException($"Filtr '{name}~{op}~{rawValue}' se nepodařilo sestavit: {ex.InnerException.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static MethodInfo? FindOperatorMethod(Type itemType, string methodName)
|
||||
{
|
||||
var candidates = itemType
|
||||
.GetMethods(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Where(m => m.Name == methodName && m.GetParameters().Length == 1)
|
||||
.ToList();
|
||||
|
||||
if (candidates.Count <= 1)
|
||||
{
|
||||
return candidates.FirstOrDefault();
|
||||
}
|
||||
|
||||
// Some filter items (e.g. the Id filter) overload Contains with a scalar and a collection —
|
||||
// prefer the scalar overload for a single query value.
|
||||
return candidates.FirstOrDefault(m =>
|
||||
{
|
||||
var pt = m.GetParameters()[0].ParameterType;
|
||||
return pt == typeof(string) || !typeof(IEnumerable).IsAssignableFrom(pt);
|
||||
}) ?? candidates[0];
|
||||
}
|
||||
|
||||
private static object ConvertValue(string raw, Type target, string fieldName)
|
||||
{
|
||||
var type = Nullable.GetUnderlyingType(target) ?? target;
|
||||
|
||||
try
|
||||
{
|
||||
if (type == typeof(string))
|
||||
{
|
||||
return raw;
|
||||
}
|
||||
if (type.IsEnum)
|
||||
{
|
||||
return int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var numeric)
|
||||
? Enum.ToObject(type, numeric)
|
||||
: Enum.Parse(type, raw, ignoreCase: true);
|
||||
}
|
||||
if (type == typeof(DateTime))
|
||||
{
|
||||
return DateTime.Parse(raw, CultureInfo.InvariantCulture, DateTimeStyles.None);
|
||||
}
|
||||
if (type == typeof(bool))
|
||||
{
|
||||
return bool.Parse(raw);
|
||||
}
|
||||
if (type == typeof(Guid))
|
||||
{
|
||||
return Guid.Parse(raw);
|
||||
}
|
||||
return Convert.ChangeType(raw, type, CultureInfo.InvariantCulture);
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException or ArgumentException)
|
||||
{
|
||||
throw new ArgumentException($"Hodnotu '{raw}' filtru '{fieldName}' nelze převést na typ {type.Name}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user