Files
idoklad/Client/ListModifiers.cs
2026-07-14 06:21:37 +02:00

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> (&gt;), <c>gte</c> (≥),
/// <c>lt</c> (&lt;), <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}.");
}
}
}