249 lines
10 KiB
C#
249 lines
10 KiB
C#
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}.");
|
|
}
|
|
}
|
|
}
|