using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Reflection; using GB5Shared.DTO.Framework.Criteria; namespace GB5Shared.ListQuery { // ───────────────────────────────────────────────────────────────────── // ICriteria – universal base for all list criteria // // Every criteria class in the system implements this interface. // The four fields (SearchText, OUId, PageSize, PageOffset) are // universal across all list endpoints. // ───────────────────────────────────────────────────────────────────── public interface ICriteria { string? SearchText { get; } int? OUId { get; } int PageSize { get; } int PageOffset { get; } } // ───────────────────────────────────────────────────────────────────── // ISortable – opt-in sort support for list queries // // SortBy : logical column name (validated against QB allowlist) // SortDesc : true = DESC, false = ASC // SortFields : compound sort tokens e.g. ["headerdate:desc","partyname:asc"] // When SortFields is non-empty it takes precedence over SortBy. // ───────────────────────────────────────────────────────────────────── public interface ISortable { string? SortBy { get; } bool SortDesc { get; } string[]? SortFields { get; } } // ───────────────────────────────────────────────────────────────────── // IPageable – extends ICriteria with keyset-paging opt-in // // CursorId = null → offset paging (default; PageOffset/PageSize apply) // CursorId = N → keyset paging (infinite-scroll only; no page-jump) // SkipCount = true → omit COUNT(*) OVER() for pure infinite-scroll speed // // Keyset guardrail: only suitable when sort is on a stable PK column // and the endpoint is infinite-scroll — never for "jump to page N" UIs. // ───────────────────────────────────────────────────────────────────── public interface IPageable : ICriteria { int? CursorId { get; } // null = offset paging; non-null = keyset bool SkipCount { get; } // true = omit COUNT(*) OVER() } // ───────────────────────────────────────────────────────────────────── // IListCriteria – convenience combinator: ICriteria + ISortable + IPageable // // Implement this as the base for all standard list query criteria. // The four ICriteria fields plus sort and paging extensions in one interface. // ───────────────────────────────────────────────────────────────────── public interface IListCriteria : IPageable, ISortable { } // ───────────────────────────────────────────────────────────────────── // [CriteriaField] – maps CriteriaDTO field names to typed properties // // Keys : one or more FieldName values (case-insensitive) // IsFlag : presence of the key alone = true (no value needed) // Delimiter : for comma-separated array values (default ",") // ───────────────────────────────────────────────────────────────────── [AttributeUsage(AttributeTargets.Property)] public sealed class CriteriaFieldAttribute : Attribute { public string[] Keys { get; } public bool IsFlag { get; init; } = false; public string Delimiter { get; init; } = ","; public CriteriaFieldAttribute(params string[] keys) => Keys = keys; } // ───────────────────────────────────────────────────────────────────── // CriteriaBinder – generic binder written once for all criteria // // Usage: // var c = CriteriaBinder.Bind(criteriaDTO); // // Reflection is done once per type T and cached in a ConcurrentDictionary. // All subsequent calls are pure dictionary lookups (~1µs). // // Search text resolution: // • Query-param searchText is injected by the router into a synthetic // CriteriaDTO section under the field name "headername" (see // IQueryRouter helpers). When both exist the query-param value wins // because the synthetic section is appended last and the binder // processes sections in order – later values overwrite earlier ones. // • Legacy clients that embed "headername"/"searchtext" inside the // body CriteriaDTO continue to work unchanged when no query param // is supplied. // ───────────────────────────────────────────────────────────────────── public static class CriteriaBinder { private static readonly ConcurrentDictionary>> _cache = new(); /// /// Binds a (and an optional user-level criteria DTO) /// to a strongly typed criteria instance of type . /// public static T Bind(CriteriaDTO dto, CriteriaDTO? userDto = null) where T : ICriteria, new() { var setters = _cache.GetOrAdd(typeof(T), BuildSetters); var instance = new T(); foreach (var (key, value) in Flatten(dto).Concat(Flatten(userDto))) if (setters.TryGetValue(key, out var set)) set(instance, value); return instance; } // ── Reflection map (built once per type) ───────────────────────── private static IReadOnlyDictionary> BuildSetters(Type type) { var map = new Dictionary>(StringComparer.OrdinalIgnoreCase); foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) { var attr = prop.GetCustomAttribute(); if (attr is null) continue; var setter = MakeSetter(prop, attr); foreach (var key in attr.Keys) map[key] = setter; } return map; } private static Action MakeSetter(PropertyInfo prop, CriteriaFieldAttribute attr) { var type = Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType; return (obj, raw) => { if (raw is null && !attr.IsFlag) return; object? value = attr.IsFlag ? (object)true : Convert(raw, type, attr.Delimiter); try { prop.SetValue(obj, value); } catch { /* skip malformed values gracefully */ } }; } private static object? Convert(object? raw, Type type, string delimiter) { var str = raw?.ToString(); if (string.IsNullOrWhiteSpace(str)) return null; if (type == typeof(int)) return int.Parse(str); if (type == typeof(long)) return long.Parse(str); if (type == typeof(bool)) return bool.Parse(str); if (type == typeof(decimal)) return decimal.Parse(str); if (type == typeof(string)) return str; if (type == typeof(DateTime)) return DateTime.Parse(str); if (type == typeof(int[])) return str.Split(delimiter, StringSplitOptions.RemoveEmptyEntries) .Select(x => int.Parse(x.Trim())).ToArray(); if (type == typeof(string[])) return str.Split(delimiter, StringSplitOptions.RemoveEmptyEntries) .Select(x => x.Trim()).ToArray(); if (type == typeof(short)) return short.Parse(str); if (type == typeof(byte)) return byte.Parse(str); if (type == typeof(double)) return double.Parse(str); if (type == typeof(Guid)) return Guid.Parse(str); // Fallback for any other value type (e.g. custom enums): use the already-derived // string, not the raw object. `raw` is frequently a System.Text.Json.JsonElement // (how CriteriaDTO.FieldValue deserializes from the request body), and JsonElement // does not implement IConvertible — passing it to ChangeType throws "Object must // implement IConvertible." `str` is a plain string, which does. return System.Convert.ChangeType(str, type, System.Globalization.CultureInfo.InvariantCulture); } // ── Flatten CriteriaDTO to (key, value) pairs ──────────────────── private static IEnumerable<(string key, object? value)> Flatten(CriteriaDTO? dto) { if (dto is null) yield break; foreach (var s in dto.SectionCriteriaList) foreach (var a in s.AttributesCriteriaList) yield return (a.FieldName.ToLowerInvariant(), a.FieldValue); } } // ───────────────────────────────────────────────────────────────────── // CriteriaRouterHelper – helpers for router/endpoint boilerplate // ───────────────────────────────────────────────────────────────────── public static class CriteriaRouterHelper { /// /// Injects volatile query-param values (searchText, pageOffset, pageSize) into /// a synthetic CriteriaDTO section appended to the existing body criteria. /// /// Rules: /// • searchTextParam is only injected when non-null (query param supplied). /// If null, the body CriteriaDTO may already contain "headername"/"searchtext" /// from a legacy client — those remain in effect. /// • pageOffset and pageSize are always injected (overriding any body defaults). /// public static CriteriaDTO WithSearchAndPaging( CriteriaDTO dto, string? searchTextParam, int pageOffset, int pageSize) { var attrs = new List { new AttributesCriteriaDTO { FieldName = "pageoffset", FieldValue = pageOffset }, new AttributesCriteriaDTO { FieldName = "pagesize", FieldValue = pageSize }, }; // Only override search text when the caller explicitly supplied it if (searchTextParam is not null) attrs.Add(new AttributesCriteriaDTO { FieldName = "headername", FieldValue = searchTextParam }); var extra = new SectionCriteriaDTO { AttributesCriteriaList = attrs }; var sections = new List(dto.SectionCriteriaList) { extra }; return new CriteriaDTO { SectionCriteriaList = sections }; } /// /// Reads a single integer discriminator field from a flattened criteria dictionary. /// public static int GetInt(Dictionary flat, string key, int defaultValue = 0) => flat.TryGetValue(key, out var v) && int.TryParse(v, out var i) ? i : defaultValue; /// /// Reads a single string discriminator field from a flattened criteria dictionary. /// public static string? GetStr(Dictionary flat, string key) => flat.TryGetValue(key, out var v) ? v : null; /// /// Flattens the nested CriteriaDTO structure into a single case-insensitive dictionary. /// When a key appears multiple times the last occurrence wins. /// public static Dictionary Flatten(CriteriaDTO dto) { var d = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var s in dto.SectionCriteriaList) foreach (var a in s.AttributesCriteriaList) d[a.FieldName] = a.FieldValue?.ToString(); return d; } } }