< Summary

Information
Class: OzonOrdersWeb.WebServices.Grid.ServerGridFacet
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/WebServices/Grid/ServerGridQuery.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 2
Coverable lines: 2
Total lines: 476
Line coverage: 0%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_Value()100%210%
get_Count()100%210%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/WebServices/Grid/ServerGridQuery.cs

#LineLine coverage
 1using System.Globalization;
 2using System.Linq.Expressions;
 3using System.Reflection;
 4using System.Text.Json;
 5using Microsoft.EntityFrameworkCore;
 6using OzonOrdersWeb.WebServices.Orders.Grid;
 7
 8namespace OzonOrdersWeb.WebServices.Grid;
 9
 10/// <summary>
 11/// Запрос блока строк серверного грида (AG Grid Infinite Row Model) для страниц на EF-запросах:
 12/// диапазон, сортировки, модель фильтров AG Grid, быстрый поиск, параметры страницы (вкладка, фильтр верхнего уровня).
 13/// </summary>
 14public sealed class ServerGridRequest
 15{
 16    public int StartRow { get; set; }
 17    public int EndRow { get; set; } = 100;
 18    public List<AgGridSortModelItem> SortModel { get; set; } = [];
 19    public Dictionary<string, AgGridFilterModelItem> FilterModel { get; set; } = new(StringComparer.Ordinal);
 20    public string? Search { get; set; }
 21    /// <summary>Параметры страницы вне колонок (например, orderNumber для поиска заказа).</summary>
 22    public Dictionary<string, string?> Params { get; set; } = new(StringComparer.OrdinalIgnoreCase);
 23    /// <summary>Для счётчиков значений: колонка.</summary>
 24    public string? ColId { get; set; }
 25}
 26
 27public enum ServerGridColumnType
 28{
 29    Text,
 30    Number,
 31    Date,
 32    Bool
 33}
 34
 35/// <summary>Колонка whitelist: colId → выражение над сущностью (транслируется EF в SQL).</summary>
 36public sealed class ServerGridColumn
 37{
 38    public required LambdaExpression Selector { get; init; }
 39    public required ServerGridColumnType Type { get; init; }
 40    public bool Sortable { get; init; } = true;
 41    public bool Filterable { get; init; } = true;
 42
 43    /// <summary>
 44    /// Текстовый фильтр колонки сравнивает значение и строку фильтра без разделителей (артикулы):
 45    /// «1020479SX» находит «10-20479-SX». Пустые/непустые проверяются по исходному значению.
 46    /// </summary>
 47    public bool NormalizeText { get; init; }
 48    public Type ValueType => Selector.ReturnType;
 49}
 50
 51/// <summary>
 52/// Описание серверного грида: whitelist колонок, поля быстрого поиска и порядок по умолчанию.
 53/// Колонки вне whitelist в запросе игнорируются — значения пользователя уходят только константами выражений (параметры 
 54/// </summary>
 55public sealed class ServerGridDefinition<T>
 56{
 57    public Dictionary<string, ServerGridColumn> Columns { get; } = new(StringComparer.Ordinal);
 58    public List<Expression<Func<T, string?>>> SearchFields { get; } = new();
 59
 60    /// <summary>
 61    /// Поля, которые быстрый поиск сравнивает без разделителей: «1020479SX» находит «10-20479-SX» и «1020479_SX».
 62    /// Разделители вырезаются и в значении поля (REPLACE в SQL), и в строке поиска.
 63    /// </summary>
 64    public List<Expression<Func<T, string?>>> NormalizedSearchFields { get; } = new();
 65    public required Func<IQueryable<T>, IOrderedQueryable<T>> DefaultOrder { get; init; }
 66    /// <summary>Добор порядка после пользовательской сортировки (стабильная пагинация), обычно Id.</summary>
 67    public required Expression<Func<T, long>> TieBreaker { get; init; }
 68
 69    public ServerGridDefinition<T> Col<TValue>(string colId, Expression<Func<T, TValue>> selector,
 70        ServerGridColumnType? type = null, bool sortable = true, bool filterable = true, bool normalizeText = false)
 71    {
 72        Columns[colId] = new ServerGridColumn
 73        {
 74            Selector = selector,
 75            Type = type ?? InferType(typeof(TValue)),
 76            Sortable = sortable,
 77            Filterable = filterable,
 78            NormalizeText = normalizeText
 79        };
 80        return this;
 81    }
 82
 83    public ServerGridDefinition<T> Search(params Expression<Func<T, string?>>[] fields)
 84    {
 85        SearchFields.AddRange(fields);
 86        return this;
 87    }
 88
 89    public ServerGridDefinition<T> SearchNormalized(params Expression<Func<T, string?>>[] fields)
 90    {
 91        NormalizedSearchFields.AddRange(fields);
 92        return this;
 93    }
 94
 95    private static ServerGridColumnType InferType(Type type)
 96    {
 97        var t = Nullable.GetUnderlyingType(type) ?? type;
 98        if (t == typeof(string) || t.IsEnum) return ServerGridColumnType.Text;
 99        if (t == typeof(bool)) return ServerGridColumnType.Bool;
 100        if (t == typeof(DateTime) || t == typeof(DateTimeOffset)) return ServerGridColumnType.Date;
 101        return ServerGridColumnType.Number;
 102    }
 103}
 104
 105public sealed class ServerGridFacet
 106{
 0107    public string Value { get; set; } = string.Empty;
 0108    public int Count { get; set; }
 109}
 110
 111/// <summary>AG Grid → IQueryable: фильтры, сортировка, поиск, блок, счётчики значений.</summary>
 112public static class ServerGridQuery
 113{
 114    public const int MaxBlockSize = 500;
 115    public const int MaxConditions = 40;
 116    public const int MaxSorts = 5;
 117    public const int MaxSetValues = 500;
 118    public const int MaxTextLength = 300;
 119    private const int MaxFacetValues = 500;
 120
 121    public static string? Validate(ServerGridRequest? request)
 122    {
 123        if (request is null) return "Пустой запрос.";
 124        if (request.StartRow < 0 || request.EndRow <= request.StartRow) return "Некорректный диапазон строк.";
 125        if (request.EndRow - request.StartRow > MaxBlockSize) return $"Слишком большой блок (максимум {MaxBlockSize}).";
 126        if (request.FilterModel is { Count: > MaxConditions }) return "Слишком много фильтров.";
 127        if (request.SortModel is { Count: > MaxSorts }) return "Слишком много столбцов сортировки.";
 128        if (request.Search is { Length: > MaxTextLength }) return "Слишком длинная строка поиска.";
 129        foreach (var (colId, item) in request.FilterModel ?? new Dictionary<string, AgGridFilterModelItem>())
 130        {
 131            if (colId.Length > 64) return "Некорректный идентификатор колонки.";
 132            if (item?.Values is { Count: > MaxSetValues }) return $"Слишком много значений в фильтре «{colId}».";
 133            if (item?.Filter is { ValueKind: JsonValueKind.String } f && f.GetString()!.Length > MaxTextLength)
 134                return $"Слишком длинное значение фильтра «{colId}».";
 135        }
 136
 137        return null;
 138    }
 139
 140    public static IQueryable<T> ApplyFilters<T>(IQueryable<T> query, ServerGridDefinition<T> def, ServerGridRequest requ
 141    {
 142        var search = request.Search?.Trim();
 143        if (!string.IsNullOrEmpty(search) && (def.SearchFields.Count > 0 || def.NormalizedSearchFields.Count > 0))
 144        {
 145            var p = Expression.Parameter(typeof(T), "x");
 146            Expression? any = null;
 147            foreach (var field in def.SearchFields)
 148            {
 149                var body = Rebind(field, p);
 150                var cond = Expression.AndAlso(Expression.NotEqual(body, Expression.Constant(null, typeof(string))),
 151                    Expression.Call(body, StringContains, Expression.Constant(search)));
 152                any = any == null ? cond : Expression.OrElse(any, cond);
 153            }
 154
 155            var stripped = StripSeparators(search);
 156            if (stripped.Length > 0)
 157            {
 158                foreach (var field in def.NormalizedSearchFields)
 159                {
 160                    var body = Rebind(field, p);
 161                    var cond = Expression.AndAlso(Expression.NotEqual(body, Expression.Constant(null, typeof(string))),
 162                        Expression.Call(StripSeparatorsExpression(body), StringContains, Expression.Constant(stripped)))
 163                    any = any == null ? cond : Expression.OrElse(any, cond);
 164                }
 165            }
 166
 167            if (any != null)
 168                query = query.Where(Expression.Lambda<Func<T, bool>>(any, p));
 169        }
 170
 171        foreach (var (colId, item) in request.FilterModel ?? new Dictionary<string, AgGridFilterModelItem>())
 172        {
 173            if (colId == excludeColId || item is null) continue;
 174            if (!def.Columns.TryGetValue(colId, out var col) || !col.Filterable) continue;
 175            var predicate = BuildPredicate<T>(col, item);
 176            if (predicate != null) query = query.Where(predicate);
 177        }
 178
 179        return query;
 180    }
 181
 182    public static IQueryable<T> ApplySort<T>(IQueryable<T> query, ServerGridDefinition<T> def, IEnumerable<AgGridSortMod
 183    {
 184        IOrderedQueryable<T>? ordered = null;
 185        foreach (var sort in (sortModel ?? Enumerable.Empty<AgGridSortModelItem>()).Take(MaxSorts))
 186        {
 187            if (!def.Columns.TryGetValue(sort.ColId, out var col) || !col.Sortable) continue;
 188            var desc = string.Equals(sort.Sort, "desc", StringComparison.OrdinalIgnoreCase);
 189            var method = ordered == null
 190                ? (desc ? "OrderByDescending" : "OrderBy")
 191                : (desc ? "ThenByDescending" : "ThenBy");
 192            ordered = (IOrderedQueryable<T>)InvokeOrder(ordered ?? query, method, col.Selector);
 193        }
 194
 195        return ordered == null ? def.DefaultOrder(query) : ordered.ThenByDescending(def.TieBreaker);
 196    }
 197
 198    public static async Task<(List<T> Items, int Total)> PageAsync<T>(IQueryable<T> query, ServerGridDefinition<T> def,
 199        ServerGridRequest request, CancellationToken cancellationToken)
 200    {
 201        var filtered = ApplyFilters(query, def, request);
 202        var total = await filtered.CountAsync(cancellationToken);
 203        var start = Math.Max(0, request.StartRow);
 204        var take = Math.Clamp(request.EndRow - start, 1, MaxBlockSize);
 205        if (start >= total) return (new List<T>(), total);
 206        var items = await ApplySort(filtered, def, request.SortModel).Skip(start).Take(take).ToListAsync(cancellationTok
 207        return (items, total);
 208    }
 209
 210    /// <summary>Значения колонки с количеством строк при остальных фильтрах (GROUP BY на сервере).</summary>
 211    public static async Task<List<ServerGridFacet>> FacetsAsync<T>(IQueryable<T> query, ServerGridDefinition<T> def,
 212        ServerGridRequest request, CancellationToken cancellationToken)
 213    {
 214        if (string.IsNullOrWhiteSpace(request.ColId) || !def.Columns.TryGetValue(request.ColId, out var col)) return new
 215        var filtered = ApplyFilters(query, def, request, excludeColId: request.ColId);
 216        var method = typeof(ServerGridQuery).GetMethod(nameof(FacetsTypedAsync), BindingFlags.NonPublic | BindingFlags.S
 217            .MakeGenericMethod(typeof(T), col.ValueType);
 218        var task = (Task<List<ServerGridFacet>>)method.Invoke(null, new object[] { filtered, col.Selector, cancellationT
 219        return await task;
 220    }
 221
 222    private static async Task<List<ServerGridFacet>> FacetsTypedAsync<T, TKey>(IQueryable<T> query, LambdaExpression sel
 223    {
 224        var typed = (Expression<Func<T, TKey>>)selector;
 225        var rows = await query.GroupBy(typed)
 226            .Select(g => new { g.Key, Count = g.Count() })
 227            .OrderByDescending(g => g.Count)
 228            .Take(MaxFacetValues)
 229            .ToListAsync(ct);
 230        // NULL и "" у строк — одно значение «(пусто)» (так же их отбирает set-фильтр): счётчики складываются.
 231        return rows
 232            .GroupBy(r => FacetKey(r.Key))
 233            .Select(g => new ServerGridFacet { Value = g.Key, Count = g.Sum(r => r.Count) })
 234            .OrderByDescending(f => f.Count)
 235            .ToList();
 236    }
 237
 238    private static string FacetKey(object? key) => key switch
 239    {
 240        null => string.Empty,
 241        bool b => b ? "true" : "false",
 242        DateTime d => d.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
 243        IFormattable f => f.ToString(null, CultureInfo.InvariantCulture),
 244        _ => key.ToString() ?? string.Empty
 245    };
 246
 247    // ---------- Предикаты ----------
 248
 249    /// <summary>Разделители артикулов, которые вырезаются при нормализованном поиске.</summary>
 250    private static readonly string[] Separators = { "-", "_", " ", "/", ".", "\\", ",", "(", ")", "+", "#", "\u00A0" };
 251
 252    private static readonly MethodInfo StringReplace = typeof(string).GetMethod(nameof(string.Replace), new[] { typeof(s
 253
 254    /// <summary>Строка поиска без разделителей: остаются только буквы и цифры.</summary>
 255    public static string StripSeparators(string value) => new(value.Where(char.IsLetterOrDigit).ToArray());
 256
 257    /// <summary>Ключ артикула для сравнения: без разделителей, в верхнем регистре.</summary>
 258    public static string ArticleKey(string? value) => StripSeparators(value ?? string.Empty).ToUpperInvariant();
 259
 260    public static Expression StripSeparatorsExpression(Expression value)
 261    {
 262        foreach (var separator in Separators)
 263            value = Expression.Call(value, StringReplace, Expression.Constant(separator), Expression.Constant(string.Emp
 264        return value;
 265    }
 266
 267    private static readonly MethodInfo StringContains = typeof(string).GetMethod(nameof(string.Contains), new[] { typeof
 268    private static readonly MethodInfo StringStartsWith = typeof(string).GetMethod(nameof(string.StartsWith), new[] { ty
 269    private static readonly MethodInfo StringEndsWith = typeof(string).GetMethod(nameof(string.EndsWith), new[] { typeof
 270
 271    private static Expression<Func<T, bool>>? BuildPredicate<T>(ServerGridColumn col, AgGridFilterModelItem item)
 272    {
 273        var p = Expression.Parameter(typeof(T), "x");
 274        var value = Rebind(col.Selector, p);
 275        var body = item.FilterType switch
 276        {
 277            "set" => BuildSet(value, item.Values),
 278            "number" => col.Type == ServerGridColumnType.Number ? BuildNumber(value, item) : null,
 279            "date" => col.Type == ServerGridColumnType.Date ? BuildDate(value, item) : null,
 280            _ => col.Type == ServerGridColumnType.Text && value.Type == typeof(string) ? BuildText(value, item, col.Norm
 281        };
 282        return body == null ? null : Expression.Lambda<Func<T, bool>>(body, p);
 283    }
 284
 285    private static Expression? BuildText(Expression value, AgGridFilterModelItem item, bool normalize = false)
 286    {
 287        var nul = Expression.Constant(null, typeof(string));
 288        var empty = Expression.Constant(string.Empty);
 289        switch (item.Type)
 290        {
 291            case "blank": return Expression.OrElse(Expression.Equal(value, nul), Expression.Equal(value, empty));
 292            case "notBlank": return Expression.AndAlso(Expression.NotEqual(value, nul), Expression.NotEqual(value, empty
 293        }
 294
 295        var text = ReadString(item.Filter)?.Trim();
 296        if (string.IsNullOrEmpty(text)) return null;
 297        if (text.Length > MaxTextLength) text = text[..MaxTextLength];
 298        // Нормализованная колонка: значение и строка фильтра сравниваются без разделителей; проверки на null идут
 299        // по исходному значению (REPLACE от NULL в БД даёт NULL, а в LINQ to Objects упал бы).
 300        var original = value;
 301        if (normalize && StripSeparators(text) is { Length: > 0 } stripped)
 302        {
 303            value = StripSeparatorsExpression(value);
 304            text = stripped;
 305        }
 306        else
 307        {
 308            normalize = false;
 309        }
 310
 311        var c = Expression.Constant(text);
 312        var notNull = Expression.NotEqual(original, nul);
 313        Expression Guarded(Expression comparison) => normalize ? Expression.AndAlso(notNull, comparison) : comparison;
 314        return item.Type switch
 315        {
 316            "equals" => Guarded(Expression.Equal(value, c)),
 317            "notEqual" => Expression.OrElse(Expression.Equal(original, nul), Expression.NotEqual(value, c)),
 318            "startsWith" => Expression.AndAlso(notNull, Expression.Call(value, StringStartsWith, c)),
 319            "endsWith" => Expression.AndAlso(notNull, Expression.Call(value, StringEndsWith, c)),
 320            "notContains" => Expression.OrElse(Expression.Equal(original, nul), Expression.Not(Expression.Call(value, St
 321            _ => Expression.AndAlso(notNull, Expression.Call(value, StringContains, c))
 322        };
 323    }
 324
 325    private static Expression? BuildNumber(Expression value, AgGridFilterModelItem item)
 326    {
 327        var nullable = !value.Type.IsValueType || Nullable.GetUnderlyingType(value.Type) != null;
 328        switch (item.Type)
 329        {
 330            case "blank": return nullable ? Expression.Equal(value, Expression.Constant(null, value.Type)) : Expression.
 331            case "notBlank": return nullable ? Expression.NotEqual(value, Expression.Constant(null, value.Type)) : Expre
 332        }
 333
 334        var from = ReadDecimal(item.Filter);
 335        if (from == null) return null;
 336        var a = Const(from.Value, value.Type);
 337        return item.Type switch
 338        {
 339            "notEqual" => Expression.NotEqual(value, a),
 340            "greaterThan" => Expression.GreaterThan(value, a),
 341            "greaterThanOrEqual" => Expression.GreaterThanOrEqual(value, a),
 342            "lessThan" => Expression.LessThan(value, a),
 343            "lessThanOrEqual" => Expression.LessThanOrEqual(value, a),
 344            "inRange" => ReadDecimal(item.FilterTo) is { } to
 345                ? Expression.AndAlso(Expression.GreaterThanOrEqual(value, a), Expression.LessThanOrEqual(value, Const(to
 346                : Expression.GreaterThanOrEqual(value, a),
 347            _ => Expression.Equal(value, a)
 348        };
 349    }
 350
 351    private static Expression? BuildDate(Expression value, AgGridFilterModelItem item)
 352    {
 353        var nullable = Nullable.GetUnderlyingType(value.Type) != null;
 354        switch (item.Type)
 355        {
 356            case "blank": return nullable ? Expression.Equal(value, Expression.Constant(null, value.Type)) : Expression.
 357            case "notBlank": return nullable ? Expression.NotEqual(value, Expression.Constant(null, value.Type)) : Expre
 358        }
 359
 360        if (!TryParseDate(item.DateFrom, out var day)) return null;
 361        var start = Const(day.Date, value.Type);
 362        var nextDay = Const(day.Date.AddDays(1), value.Type);
 363        switch (item.Type)
 364        {
 365            case "greaterThan": return Expression.GreaterThanOrEqual(value, nextDay);
 366            case "lessThan": return Expression.LessThan(value, start);
 367            case "notEqual": return Expression.OrElse(Expression.LessThan(value, start), Expression.GreaterThanOrEqual(v
 368            case "inRange":
 369                var end = TryParseDate(item.DateTo, out var to) ? Const(to.Date.AddDays(1), value.Type) : nextDay;
 370                return Expression.AndAlso(Expression.GreaterThanOrEqual(value, start), Expression.LessThan(value, end));
 371            default:
 372                return Expression.AndAlso(Expression.GreaterThanOrEqual(value, start), Expression.LessThan(value, nextDa
 373        }
 374    }
 375
 376    private static Expression BuildSet(Expression value, List<string>? rawValues)
 377    {
 378        var values = (rawValues ?? new List<string>()).Distinct(StringComparer.Ordinal).Take(MaxSetValues).ToList();
 379        if (values.Count == 0) return Expression.Constant(false);
 380        var underlying = Nullable.GetUnderlyingType(value.Type) ?? value.Type;
 381        var hasBlank = values.Any(string.IsNullOrEmpty);
 382        var nonBlank = values.Where(v => !string.IsNullOrEmpty(v)).ToList();
 383        Expression? result = null;
 384
 385        if (nonBlank.Count > 0)
 386        {
 387            var listType = typeof(List<>).MakeGenericType(value.Type);
 388            var list = (System.Collections.IList)Activator.CreateInstance(listType)!;
 389            foreach (var v in nonBlank)
 390            {
 391                var converted = ConvertSetValue(v, underlying);
 392                if (converted != null) list.Add(converted);
 393            }
 394
 395            if (list.Count > 0)
 396            {
 397                var contains = listType.GetMethod(nameof(List<object>.Contains))!;
 398                result = Expression.Call(Expression.Constant(list), contains, value);
 399            }
 400        }
 401
 402        if (hasBlank && (!value.Type.IsValueType || Nullable.GetUnderlyingType(value.Type) != null))
 403        {
 404            Expression blank = Expression.Equal(value, Expression.Constant(null, value.Type));
 405            if (value.Type == typeof(string)) blank = Expression.OrElse(blank, Expression.Equal(value, Expression.Consta
 406            result = result == null ? blank : Expression.OrElse(result, blank);
 407        }
 408
 409        return result ?? Expression.Constant(false);
 410    }
 411
 412    private static object? ConvertSetValue(string raw, Type type)
 413    {
 414        try
 415        {
 416            if (type == typeof(string)) return raw;
 417            if (type == typeof(bool)) return raw is "true" or "1" or "True";
 418            if (type.IsEnum) return Enum.Parse(type, raw, ignoreCase: true);
 419            if (type == typeof(DateTime)) return DateTime.Parse(raw, CultureInfo.InvariantCulture);
 420            if (type == typeof(DateTimeOffset)) return DateTimeOffset.Parse(raw, CultureInfo.InvariantCulture, DateTimeS
 421            return Convert.ChangeType(raw, type, CultureInfo.InvariantCulture);
 422        }
 423        catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException or ArgumentExcepti
 424        {
 425            return null;
 426        }
 427    }
 428
 429    private static Expression Const(object value, Type targetType)
 430    {
 431        var underlying = Nullable.GetUnderlyingType(targetType) ?? targetType;
 432        var converted = underlying == typeof(DateTimeOffset) && value is DateTime dateTime
 433            ? new DateTimeOffset(DateTime.SpecifyKind(dateTime, DateTimeKind.Utc))
 434            : value is DateTime ? value : Convert.ChangeType(value, underlying, CultureInfo.InvariantCulture);
 435        return Expression.Convert(Expression.Constant(converted, underlying), targetType);
 436    }
 437
 438    private static bool TryParseDate(string? raw, out DateTime value)
 439    {
 440        value = default;
 441        if (string.IsNullOrWhiteSpace(raw)) return false;
 442        return DateTime.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.None, out value);
 443    }
 444
 445    private static string? ReadString(JsonElement? element) => element is { } e
 446        ? e.ValueKind switch
 447        {
 448            JsonValueKind.String => e.GetString(),
 449            JsonValueKind.Number => e.GetRawText(),
 450            _ => null
 451        }
 452        : null;
 453
 454    private static decimal? ReadDecimal(JsonElement? element)
 455    {
 456        if (element is not { } e) return null;
 457        if (e.ValueKind == JsonValueKind.Number && e.TryGetDecimal(out var d)) return d;
 458        if (e.ValueKind == JsonValueKind.String && decimal.TryParse(e.GetString()?.Replace(',', '.'), NumberStyles.Numbe
 459        return null;
 460    }
 461
 462    private static Expression Rebind(LambdaExpression lambda, ParameterExpression p) =>
 463        new ParameterReplacer(lambda.Parameters[0], p).Visit(lambda.Body);
 464
 465    private static IQueryable InvokeOrder(IQueryable source, string method, LambdaExpression selector)
 466    {
 467        var call = Expression.Call(typeof(Queryable), method, new[] { source.ElementType, selector.ReturnType },
 468            source.Expression, Expression.Quote(selector));
 469        return source.Provider.CreateQuery(call);
 470    }
 471
 472    private sealed class ParameterReplacer(ParameterExpression from, ParameterExpression to) : ExpressionVisitor
 473    {
 474        protected override Expression VisitParameter(ParameterExpression node) => node == from ? to : base.VisitParamete
 475    }
 476}

Methods/Properties

get_Value()
get_Count()