Объединить выражения вместо использования нескольких запросов в Entity Framework

У меня есть следующий общий запрос (который уже может быть применен):

IQueryable<TEntity> queryable = DBSet<TEntity>.AsQueryable();

Тогда существует класс Provider, который выглядит следующим образом:

public class Provider<TEntity>
{
    public Expression<Func<TEntity, bool>> Condition { get; set; }

    [...]
}

Condition может быть определен для каждого экземпляра следующим образом:

Condition = entity => entity.Id == 3;

Теперь я хочу выбрать все экземпляры Provider, у которых есть Condition, который встречается хотя бы одним объектом DBSet:

List<Provider> providers = [...];
var matchingProviders = providers.Where(provider => queryable.Any(provider.Condition))

Проблема с этим: я запускаю запрос для каждого экземпляра Provider в списке. Я бы предпочел использовать один запрос для достижения того же результата. Эта тема особенно важна из-за сомнительной производительности. Как добиться одинаковых результатов с помощью одного запроса и повысить производительность с помощью операторов Linq или Expression Trees?

Ответ 1

Интересный вызов. Единственный способ, который я вижу, - построить динамически UNION ALL запрос следующим образом:

SELECT TOP 1 0 FROM Table WHERE Condition[0]
UNION ALL
SELECT TOP 1 1 FROM Table WHERE Condition[1]
...
UNION ALL
SELECT TOP 1 N-1 FROM Table WHERE Condition[N-1]

а затем используйте возвращаемые числа в качестве индекса для получения соответствующих поставщиков.

Что-то вроде этого:

var parameter = Expression.Parameter(typeof(TEntity), "e");
var indexQuery = providers
    .Select((provider, index) => queryable
        .Where(provider.Condition)
        .Take(1)
        .Select(Expression.Lambda<Func<TEntity, int>>(Expression.Constant(index), parameter)))
    .Aggregate(Queryable.Concat);

var indexes = indexQuery.ToList();
var matchingProviders = indexes.Select(index => providers[index]);

Обратите внимание, что я мог бы построить запрос без использования класса Expression, заменив выше Select на

.Select(_ => index)

но это приведет к появлению ненужного параметра SQL-запроса для каждого индекса.

Ответ 2

Вот еще одна (сумасшедшая) идея, которая пришла мне в голову. Обратите внимание, что, как и в моем предыдущем ответе, это не гарантирует лучшую производительность (на самом деле это может быть хуже). Это просто способ сделать то, что вы задаете, с помощью одного SQL-запроса.

Здесь мы собираемся создать запрос, который возвращает одиночный string с длиной N, состоящий из символов "0" и "1" с символом "1", обозначающим совпадение (что-то вроде массива строковых бит). Запрос будет использовать мою любимую группу с помощью постоянной техники, чтобы динамически строить что-то вроде этого:

var matchInfo = queryable
    .GroupBy(e => 1)
    .Select(g =>
        (g.Max(Condition[0] ? "1" : "0")) +
        (g.Max(Condition[1] ? "1" : "0")) +
            ...
        (g.Max(Condition[N-1] ? "1" : "0")))
    .FirstOrDefault() ?? "";

И вот код:

var group = Expression.Parameter(typeof(IGrouping<int, TEntity>), "g");

var concatArgs = providers.Select(provider => Expression.Call(
        typeof(Enumerable), "Max", new[] { typeof(TEntity), typeof(string) },
        group, Expression.Lambda(
            Expression.Condition(
                provider.Condition.Body, Expression.Constant("1"), Expression.Constant("0")),
            provider.Condition.Parameters)));

var concatCall = Expression.Call(
    typeof(string).GetMethod("Concat", new[] { typeof(string[]) }),
    Expression.NewArrayInit(typeof(string), concatArgs));

var selector = Expression.Lambda<Func<IGrouping<int, TEntity>, string>>(concatCall, group);

var matchInfo = queryable
    .GroupBy(e => 1)
    .Select(selector)
    .FirstOrDefault() ?? "";

var matchingProviders = matchInfo.Zip(providers,
    (match, provider) => match == '1' ? provider : null)
    .Where(provider => provider != null)
    .ToList();

Наслаждайтесь:)

PS По моему мнению, этот запрос будет работать с постоянной скоростью (относительно количества и типа условий, т.е. можно считать O (N) лучшим, худшие и средние случаи, где N - количество записей в таблице), поскольку база данных должна всегда выполнять полное сканирование таблицы. Тем не менее, будет интересно узнать, какова фактическая производительность, но, скорее всего, что-то подобное просто не стоит усилий.

Обновление: Что касается награды и обновленного требования:

Найти быстрый запрос, который только считывает запись таблицы один раз и завершает запрос, если уже выполнены все условия

Нет стандартной конструкции SQL (даже не говоря о переводе запросов LINQ), которая удовлетворяет обоим условиям. Конструкции, допускающие ранний конец типа EXISTS, могут использоваться для одного условия, поэтому при выполнении для нескольких условий будет нарушено первое правило чтения записи таблицы только один раз. Хотя конструкции, которые используют агрегаты, подобные в этом ответе, удовлетворяют первому правилу, но для получения совокупного значения они должны читать все записи, поэтому не могут выйти раньше.

В скором времени нет запросов, которые могут удовлетворить оба требования. Что касается быстрой части, это действительно зависит от размера данных, количества и типа условий, табличных индексов и т.д., Поэтому снова нет "лучшего" общего решения для всех случаев.

Ответ 3

Основываясь на этом Сообщение от @Ivan, я создал выражение, которое в некоторых случаях немного быстрее.

Он использует Any вместо Max для получения желаемых результатов.

var group = Expression.Parameter(typeof(IGrouping<int, TEntity>), "g");

var anyMethod = typeof(Enumerable)
    .GetMethods()
    .First(m => m.Name == "Any" && m.GetParameters()
    .Count() == 2)
    .MakeGenericMethod(typeof(TEntity));

var concatArgs = Providers.Select(provider => 
    Expression.Call(anyMethod, group, 
    Expression.Lambda(provider.Condition.Body, provider.Condition.Parameters)));

var convertExpression = concatArgs.Select(concat =>
    Expression.Condition(concat, Expression.Constant("1"), Expression.Constant("0")));

var concatCall = Expression.Call(
    typeof(string).GetMethod("Concat", new[] { typeof(string[]) }),
    Expression.NewArrayInit(typeof(string), convertExpression));

var selector = Expression.Lambda<Func<IGrouping<int, TEntity>, string>>(concatCall, group);

var matchInfo = queryable
    .GroupBy(e => 1)
    .Select(selector)
    .First();

var MatchingProviders = matchInfo.Zip(Providers,
    (match, provider) => match == '1' ? provider : null)
    .Where(provider => provider != null)
    .ToList();

Ответ 4

Подход, который я пробовал здесь, заключался в создании Conditions и вложенности в один Expression. Если выполняется один из Conditions, мы получаем для него индекс Provider.

private static Expression NestedExpression(
    IEnumerable<Expression<Func<TEntity, bool>>> expressions, 
    int startIndex = 0)
{
    var range = expressions.ToList();
    range.RemoveRange(0, startIndex);

    if (range.Count == 0)
        return Expression.Constant(-1);

    return Expression.Condition(
        range[0].Body, 
        Expression.Constant(startIndex), 
        NestedExpression(expressions, ++startIndex));
}

Поскольку Expressions все еще может использовать разные ParameterExpressions, нам нужно ExpressionVisitor переписать их:

private class PredicateRewriterVisitor : ExpressionVisitor
{
    private readonly ParameterExpression _parameterExpression;

    public PredicateRewriterVisitor(ParameterExpression parameterExpression)
    {
        _parameterExpression = parameterExpression;
    }

    protected override Expression VisitParameter(ParameterExpression node)
    {
        return _parameterExpression;
    }
}

Для перезаписи нам нужно только вызвать этот метод:

private static Expression<Func<T, bool>> Rewrite<T>(
    Expression<Func<T, bool>> exp, 
    ParameterExpression parameterExpression)
{
    var newExpression = new PredicateRewriterVisitor(parameterExpression).Visit(exp);
    return (Expression<Func<T, bool>>)newExpression;
}

Сам запрос и выбор экземпляров Provider работают следующим образом:

var parameterExpression = Expression.Parameter(typeof(TEntity), "src");
var conditions = Providers.Select(provider => 
    Rewrite(provider.Condition, parameterExpression)
);

var nestedExpression = NestedExpression(conditions);
var lambda = Expression.Lambda<Func<TEntity, int>>(nestedExpression, parameterExpression);

var matchInfo = queryable.Select(lambda).Distinct();
var MatchingProviders = Providers.Where((provider, index) => matchInfo.Contains(index));

Примечание. Еще одна опция, которая также не очень быстро работает

Ответ 5

Вот еще один взгляд на проблему, которая не имеет ничего общего с выражениями.

Поскольку основная цель - повысить производительность, если попытки создать результат с помощью одного запроса не помогут, мы могли бы попытаться повысить скорость, распараллеливая выполнение исходного решения для нескольких запросов.

Так как это действительно запрос LINQ to Objects (который внутренне выполняет несколько запросов EF), теоретически это должно быть простым делом превратить его в PLINQ, вставив AsParallel, как это (не работает):

var matchingProviders = providers
    .AsParallel()
    .Where(provider => queryable.Any(provider.Condition))
    .ToList();

Однако оказывается, что EF DbContext не подходит для многопоточного доступа, и выше просто генерирует ошибки времени выполнения. Поэтому мне пришлось прибегать к TPL, используя один из Parallel.ForEach перегрузки, которые позволяют нам предоставлять локальное состояние, которое я использовал для выделения нескольких экземпляров DbContext во время выполнения.

Окончательный рабочий код выглядит следующим образом:

var matchingProviders = new List<Provider<TEntity>>();
Parallel.ForEach(providers,
    () => new
    {
        context = new MyDbContext(),
        matchingProviders = new List<Provider<TEntity>>()
    },
    (provider, state, data) =>
    {
        if (data.context.Set<TEntity>().Any(provider.Condition))
            data.matchingProviders.Add(provider);
        return data;
    },
    data =>
    {
        data.context.Dispose();
        if (data.matchingProviders.Count > 0)
        {
            lock (matchingProviders)
                matchingProviders.AddRange(data.matchingProviders);
        }
    }
);

Если у вас многоядерный процессор (который в настоящее время нормальный) и хороший сервер базы данных, это должно дать вам улучшения, которые вы ищете.