using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Dapper; using GB5Shared.DTO.Framework.Login; using GB5Shared.QueryExecutor; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using static GB5Shared.QueryExecutor.QueryExecutor; namespace GB5Shared.ListQuery { // ───────────────────────────────────────────────────────────────────── // IListQuery // // Marker interface for a query object. Every list in the system is // represented as a record that implements this interface. // The query object IS the request — it carries the typed criteria. // // Example: // public sealed record PendingMMDocumentsQuery(PendingMMDocumentCriteria Criteria) // : IListQuery; // ───────────────────────────────────────────────────────────────────── public interface IListQuery { } // ───────────────────────────────────────────────────────────────────── // IListQuery // // Extends the marker for queries that carry a typed Criteria property. // Implement this (instead of the plain marker) when backed by a // SqlListHandler<,,> subclass — the base reads query.Criteria directly. // // Example: // public sealed record FooListQuery(FooCriteria Criteria) // : IListQuery; // ───────────────────────────────────────────────────────────────────── public interface IListQuery : IListQuery where TCriteria : class { TCriteria Criteria { get; } } // ───────────────────────────────────────────────────────────────────── // IListHandler // // One handler per query type. The handler owns: // • Building the SQL (via a QueryBuilder method) // • Executing the query via IQueryExecutor // • Returning a PagedResult or IAsyncEnumerable // // Handlers never contain business logic, caching logic, or HTTP concerns. // // Pattern A — Direct call (simple modules, single query variant): // Inject IListHandler directly into the BLL method. // // Pattern B — Routed (legacy fat methods, multi-variant endpoint): // Let IQueryRouter build the correct IListQuery and dispatch via IListDispatcher. // ───────────────────────────────────────────────────────────────────── public interface IListHandler where TQuery : IListQuery { /// Returns a paged result materialised into memory. Task> HandleAsync(TQuery query, LoginDTO login, CancellationToken ct = default); /// /// Streams results one row at a time (constant memory). /// Used by NDJSON streaming endpoints — client reads rows as they arrive. /// IAsyncEnumerable StreamAsync(TQuery query, LoginDTO login, CancellationToken ct = default); } // ───────────────────────────────────────────────────────────────────── // IListDispatcher // // Resolves and invokes the correct IListHandler for a given IListQuery. // Used by IQueryRouter implementations (Pattern B). // // No manual switch statements — DI is the registry. // The dispatcher resolves IListHandler from the DI container. // ───────────────────────────────────────────────────────────────────── public interface IListDispatcher { Task> DispatchAsync( IListQuery query, LoginDTO login, CancellationToken ct = default); IAsyncEnumerable StreamAsync( IListQuery query, LoginDTO login, CancellationToken ct = default); } // ───────────────────────────────────────────────────────────────────── // IQueryRouter // // The BLL/SL routing layer. For a single FE endpoint that can produce // multiple different query shapes based on discriminator fields in the // CriteriaDTO, the router: // 1. Reads 1–3 discriminator fields from the raw CriteriaDTO // 2. Calls CriteriaBinder.Bind() to get a typed criteria // 3. Builds the correct IListQuery // 4. Dispatches via IListDispatcher // // Use this ONLY when a single endpoint serves multiple query variants. // For simple modules, call IListHandler.HandleAsync() directly. // ───────────────────────────────────────────────────────────────────── public interface IQueryRouter { Task RouteAsync( GB5Shared.DTO.Framework.Criteria.CriteriaDTO criteria, string? searchText, int pageOffset, int pageSize, LoginDTO login, CancellationToken ct = default); } // ───────────────────────────────────────────────────────────────────── // IQueryBuilder // // The ONE thing a module developer writes per list query variant. // Implement this in the module's QB class — no handler file needed. // GenericListHandler is wired automatically by AddListInfrastructure(). // // The Build() method receives the full query (which carries Criteria) // and the resolved dialect. Return parameterised SQL only — no execution. // // Sort safety: Build() MUST validate sortBy against an internal allowlist // via SqlClauses.OrderBy(). Never concatenate client sort columns into SQL. // // Example: // public sealed class PendingMMDocumentsQB // : IQueryBuilder // { // private static readonly IReadOnlyDictionary _sort = // new Dictionary(StringComparer.OrdinalIgnoreCase) // { ["headerdate"] = "h.MMHEADERDATE", ["partyname"] = "pt.PARTYNAME" }; // // public (string Sql, DynamicParameters Params) Build( // PendingMMDocumentsQuery query, ISqlDialect dialect) // { // var orderBy = SqlClauses.OrderBy( // query.Criteria.SortBy, query.Criteria.SortDesc, // _sort, defaultCol: "h.MMHEADERID DESC"); // return PendingAllocationQB.BuildPendingMMDocuments( // query.Criteria, dialect, orderBy); // } // } // ───────────────────────────────────────────────────────────────────── public interface IQueryBuilder where TQuery : IListQuery { (string Sql, DynamicParameters Params) Build( TQuery query, LoginDTO login, ISqlDialect dialect); } // ───────────────────────────────────────────────────────────────────── // ListDispatcher – resolves handler via DI service provider // // Handlers are resolved by type using IServiceProvider. // MethodInfo invocations are cached after the first call per handler type. // ───────────────────────────────────────────────────────────────────── public sealed class ListDispatcher : IListDispatcher { private readonly IServiceProvider _services; // Cache reflected MethodInfo to avoid repeated GetMethod calls private static readonly ConcurrentDictionary _handleMethods = new(); private static readonly ConcurrentDictionary _streamMethods = new(); public ListDispatcher(IServiceProvider services) => _services = services; public Task> DispatchAsync( IListQuery query, LoginDTO login, CancellationToken ct = default) { var handler = ResolveHandler(query.GetType(), out var handlerType); var invoke = _handleMethods.GetOrAdd(handlerType, t => t.GetMethod(nameof(IListHandler, TResult>.HandleAsync))!); return (Task>)invoke.Invoke(handler, [query, login, ct])!; } public IAsyncEnumerable StreamAsync( IListQuery query, LoginDTO login, CancellationToken ct = default) { var handler = ResolveHandler(query.GetType(), out var handlerType); var invoke = _streamMethods.GetOrAdd(handlerType, t => t.GetMethod(nameof(IListHandler, TResult>.StreamAsync))!); return (IAsyncEnumerable)invoke.Invoke(handler, [query, login, ct])!; } private object ResolveHandler(Type queryType, out Type handlerType) { handlerType = typeof(IListHandler<,>).MakeGenericType(queryType, typeof(TResult)); return _services.GetService(handlerType) ?? throw new InvalidOperationException( $"No handler registered for query type '{queryType.Name}'. " + $"Register IListHandler<{queryType.Name}, {typeof(TResult).Name}> in DI."); } } // ───────────────────────────────────────────────────────────────────── // GenericListHandler // // The universal handler. Owns (once, here in GB5Shared): // • Guard execution (IQueryGuard — all run; empty = permissive) // • Dialect resolution via SqlDialectFactory // • IQueryBuilder.Build() // • IQueryExecutor.QueryPagedAsync / QueryStreamAsync // • Timing + structured slow-query log (>2000ms → LogWarning) // // Registered automatically by AddListInfrastructure() for every // IQueryBuilder found in the scanned assemblies. // Module developers do NOT register or subclass this. // // QueryIntent is resolved once per TQuery type (cached in QueryIntentResolver). // Phase 2 will use it for DB routing — today it is metadata only. // ───────────────────────────────────────────────────────────────────── public sealed class GenericListHandler : IListHandler where TQuery : IListQuery { // Resolved once per TQuery type — zero overhead after first call private static readonly QueryIntent _intent = QueryIntentResolver.Get(typeof(TQuery)); private readonly IQueryExecutor _qe; private readonly IQueryBuilder _qb; private readonly IEnumerable> _guards; private readonly ILogger> _logger; public GenericListHandler( IQueryExecutor qe, IQueryBuilder qb, IEnumerable> guards, ILogger> logger) { _qe = qe; _qb = qb; _guards = guards; _logger = logger; } /// public async Task> HandleAsync( TQuery query, LoginDTO login, CancellationToken ct = default) { RunGuards(query, login); var dialect = SqlDialectFactory.Get(login.DatabaseType); var (sql, param) = _qb.Build(query, login, dialect); var sw = Stopwatch.StartNew(); try { return await _qe.QueryPagedAsync(login, sql, param) .ConfigureAwait(false); } finally { sw.Stop(); LogTiming(sw.Elapsed); } } /// public async IAsyncEnumerable StreamAsync( TQuery query, LoginDTO login, [EnumeratorCancellation] CancellationToken ct = default) { RunGuards(query, login); var dialect = SqlDialectFactory.Get(login.DatabaseType); var (sql, param) = _qb.Build(query, login, dialect); var sw = Stopwatch.StartNew(); await foreach (var row in _qe.QueryStreamAsync(login, sql, param, ct) .ConfigureAwait(false)) { yield return row; } LogTiming(sw.Elapsed); } private void RunGuards(TQuery query, LoginDTO login) { if (_guards == null || !_guards.Any()) return; foreach (var guard in _guards) { try { guard.Validate(query, login); } catch (Exception ex) { throw new InvalidOperationException( $"Guard validation failed: {guard.GetType().Name}", ex); } } } private void LogTiming(TimeSpan elapsed) { if (elapsed.TotalMilliseconds > 2000) _logger.LogWarning( "Slow query {QueryType} completed in {ElapsedMs}ms (intent={Intent}) — review filters/indexes", typeof(TQuery).Name, (long)elapsed.TotalMilliseconds, _intent); else _logger.LogDebug( "Query {QueryType} completed in {ElapsedMs}ms", typeof(TQuery).Name, (long)elapsed.TotalMilliseconds); } } // ───────────────────────────────────────────────────────────────────── // PagedListResult // // Returned by IListHandler.HandleAsync(). // Note: GB5Shared.QueryExecutor.PagedResult already exists with // Items + TotalCount. This type re-uses that shape under the ListQuery // namespace so handlers don't need to import QueryExecutor directly. // Internally, handlers call IQueryExecutor.QueryPagedAsync() which // returns the QueryExecutor.PagedResult and map it here. // ───────────────────────────────────────────────────────────────────── public sealed class PagedListResult { public IEnumerable Items { get; init; } = []; public int TotalCount { get; init; } public bool IsTruncated => TotalCount > (Items as ICollection)?.Count; /// /// Converts a from IQueryExecutor /// into a . /// public static PagedListResult From(PagedResult source) => new() { Items = source.Items ?? [], TotalCount = source.TotalCount }; } // ───────────────────────────────────────────────────────────────────── // DI registration helpers // ───────────────────────────────────────────────────────────────────── public static class ListQueryServiceExtensions { /// /// Registers IListDispatcher, scans for /// IQueryBuilder<TQ,TR> implementations, auto-registers /// GenericListHandler<TQ,TR> as IListHandler<TQ,TR> for each one found, /// and registers all IQueryGuard<TQuery> implementations. /// /// Replaces AddListDispatcher(). Call once in Program.cs: /// builder.Services.AddListInfrastructure(mmDALAssembly); /// public static IServiceCollection AddListInfrastructure( this IServiceCollection services, params Assembly[] scanAssemblies) { services.AddScoped(); if (scanAssemblies is null or { Length: 0 }) return services; foreach (var assembly in scanAssemblies) foreach (var type in assembly.GetTypes() .Where(t => !t.IsAbstract && !t.IsInterface)) { foreach (var iface in type.GetInterfaces() .Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IQueryBuilder<,>))) { // IQueryBuilder → register the QB class itself services.AddScoped(iface, type); // Auto-register GenericListHandler as IListHandler var args = iface.GetGenericArguments(); // [TQuery, TResult] var handlerType = typeof(GenericListHandler<,>).MakeGenericType(args); var handlerIface = typeof(IListHandler<,>).MakeGenericType(args); services.AddScoped(handlerIface, handlerType); } foreach (var iface in type.GetInterfaces() .Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IQueryGuard<>))) { // Register guard — IEnumerable> collects all registrations services.AddScoped(iface, type); } } return services; } /// Registers IListDispatcher as Scoped (no assembly scan). [Obsolete("Use AddListInfrastructure(assemblies) to also register IQueryBuilder handlers. " + "AddListDispatcher() remains for projects that register handlers manually.")] public static IServiceCollection AddListDispatcher(this IServiceCollection services) { services.AddScoped(); return services; } /// /// Registers a handler for a specific query type explicitly. /// Useful for overriding a GenericListHandler registration with a custom handler. /// public static IServiceCollection AddListHandler( this IServiceCollection services) where TQuery : IListQuery where THandler : class, IListHandler { services.AddScoped, THandler>(); return services; } } }