Automapper ProjectTo‹› не работает с Count()

У меня странная проблема с AutoMapper (я использую .NET core 3.1 и AutoMapper 10.1.1)

Я делаю простой проект для списка и простой прогнозируемый счет для общих записей:

var data = Db.Customers
            .Skip((1 - 1) * 25)
            .Take(25)
            .ProjectTo<CustomerViewModel>(Mapper.ConfigurationProvider)
            .ToList();

var count = Db.Customers
            .ProjectTo<CustomerViewModel>(Mapper.ConfigurationProvider)
            .Count();

Первая строка создает ожидаемый SQL:

exec sp_executesql N'SELECT [c].[Code], [c].[Id], [c].[Name], [c].[Website], [s].Name
FROM [Customers] AS [c]
INNER JOIN [Status] AS [s] ON [s].id = [c].StatusId
ORDER BY (SELECT 1)
OFFSET @__p_0 ROWS FETCH NEXT @__p_1 ROWS ONLY',N'@__p_0 int,@__p_1 int',@__p_0=0,@__p_1=25

Вторая строка, Count(). Кажется, полностью игнорирует проекцию:

SELECT COUNT(*)
FROM [Customers] AS [c]

В результате любой клиент с нулевым StatusId будет исключен из первого запроса, но включен в подсчет во втором. Что ломает пейджинг.

Я бы подумал, что проект должен создать что-то вроде:

SELECT COUNT(*)
FROM [Customers] AS [c]
INNER JOIN [Status] AS [s] ON [s].id = [c].StatusId

Кто-нибудь знает, почему Count() игнорирует ProjectTo<>?

Изменить
План выполнения:

value(Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryable`1[Domain.Customer]).Select(dtoCustomer => new CustomerViewModel() { Code = dtoCustomer.Code, Id = dtoCustomer.Id, Name = dtoCustomer.Name, StatusName = dtoCustomer.Status.Name, Website = dtoCustomer.Website})

Изменить план сопоставлений на 19 февраля 2021 г.
:

Сущности EF -

public class Customer
{
    public Guid Id { get; private set; }
    public string Name { get; private set; }
    public string Code { get; private set; }
    public string Website { get; private set; }
    public CustomerStatus Status { get; private set; }
    
    public Customer() { }
}

public class CustomerStatus
{
    public Guid Id { get; private set; }
    public string Name { get; private set; }
}

ВьюМодель -

public class CustomerViewModel
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public string Code { get; set; }
    public string Website { get; set; }
    public string StatusName { get; set; }
}

Отображение -

CreateMap<Customer, CustomerViewModel>();

Редактировать 20.02.2021 — Статус исключения вручную

Как указано в ответе @atiyar, вы можете вручную исключить статус. Это пересекает меня как обходной путь. Мои рассуждения таковы:

Если вы выполните этот запрос, как самый корневой запрос:

Db.Customers.ProjectTo<CustomerViewModel>(_mapper.ConfigurationProvider)

Вы получаете:

exec sp_executesql N'SELECT TOP(@__p_0) [c].[Id], [c].[Name], [c0].[Name] 
AS [StatusName]
FROM [Customers] AS [c]
INNER JOIN [CustomerStatus] AS [c0] ON [c].[StatusId] = [c0].[Id]',N'@__p_0 
int',@__p_0=5

Это показывает, что automapper понимает и может видеть, что существует необходимая связь между Status и Customer. Но когда вы применяете механизм подсчета:

Db.Customers.ProjectTo<CustomerViewModel>(_mapper.ConfigurationProvider).Count()

Внезапно понятные отношения между Статусом и Клиентом теряются.

SELECT COUNT(*)
FROM [Customers] AS [c]

По моему опыту работы с Linq, каждый шаг запроса изменяет предыдущий шаг предсказуемым образом. Я ожидал, что счет будет основан на первой команде и будет включать счет как часть этого.

Интересно, если вы выполните это:

_context.Customers.ProjectTo<CustomerViewModel>(_mapper.ConfigurationProvider).Take(int.MaxValue).Count()

Automapper применяет отношения, и результат такой, как я и ожидал:

exec sp_executesql N'SELECT COUNT(*)
FROM (
SELECT TOP(@__p_0) [c].[Id], [c].[Name], [c0].[Name] AS [Name0], [c0].[Id] 
AS [Id0]
FROM [Customers] AS [c]
INNER JOIN [CustomerStatus] AS [c0] ON [c].[StatusId] = [c0].[Id]
) AS [t]',N'@__p_0 int',@__p_0=2147483647

Редактировать 20 февраля 2021 г. — последняя версия

Кажется, поведение такое же в последней версии.

К вашему сведению: у нас есть сценарий, в котором записи регулярно импортируются из другого приложения. Мы надеялись использовать внутреннее соединение, чтобы исключить записи, которые не имеют совпадающей записи в другой таблице. Затем эти записи будут обновлены позднее в процессе импорта.

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

Редактировать 20 февраля 2021 г. – дальнейшие исследования Похоже, это выбор дизайна командой EF и оптимизация. Здесь предполагается, что если отношение не равно нулю. Тогда соединение не будет включено в качестве повышения производительности. Обойти это так, как предложил @atiyar. Спасибо за помощь всем @atiyar и @Lucian-Bargaoanu.


person Rtype    schedule 12.02.2021    source источник
comment
Хороший вопрос, я исключаю выпуск 5. Microsoft.EntityFrameworkCore: 3.1.12, Microsoft.EntityFrameworkCore.SqlServer: 3.1.12, Microsoft.EntityFrameworkCore.Tools: 3.1.12   -  person Rtype    schedule 12.02.2021
comment
Так как не могу перейти на 5.0 по техническим требованиям. Попробую откатить автомаппер на несколько версий.   -  person Rtype    schedule 14.02.2021
comment
Я не думаю, что AM виноват здесь :) Тестирование на 5.0, по крайней мере, скажет вам, была ли проблема исправлена.   -  person Lucian Bargaoanu    schedule 14.02.2021
comment
Вы можете очень легко заменить ProjectTo запросом LINQ и удалить AM из уравнения.   -  person Lucian Bargaoanu    schedule 14.02.2021
comment
ProjectTo изменяет запрос для предоставления информации от связанных сущностей на основе типа целевого объекта сопоставления. Удаление ProjectTo делает запрос ванильным, который отлично работает и не включает связанные таблицы.   -  person Rtype    schedule 15.02.2021
comment
Вы упускаете мою мысль. Проверьте план выполнения.   -  person Lucian Bargaoanu    schedule 15.02.2021
comment
Извините, я не уверен, какой смысл вы делаете? Удаление ProjectTo‹› устраняет проблему. потому что проблема в ProjectTo, а не в Linq?   -  person Rtype    schedule 15.02.2021
comment
Добавлен план выполнения.   -  person Rtype    schedule 15.02.2021
comment
@Rtype Я могу сделать вывод из вашего SQL-запроса, но вы должны добавить свою конфигурацию сопоставления, просто для ясности?   -  person atiyar    schedule 15.02.2021
comment
@atiyar, готово! надеюсь, это поможет, я немного застрял на этом. Никогда не видел, чтобы автомаппер делал это раньше.   -  person Rtype    schedule 19.02.2021
comment
Как AM не имеет значения? Он модифицирует запрос Linq для выполнения проекции. Я думаю, возможно, вы думаете, что это обычный вызов Map‹›? ProjectTo‹› фактически изменяет запрос Linq, чтобы получить связанные поля из других объектов. Как уже было сказано, без АМ работает нормально. Project‹› видит, что у меня есть связанное поле, извлекаемое в StatusName, поэтому projectTo изменяет оператор linq, добавляя Join. Затем он извлекает имя состояния для запрошенного объекта и вставляет его в проецируемую модель представления. ProjectTo‹› отличается от Map‹›   -  person Rtype    schedule 19.02.2021
comment
@Rtype Итак, ваш второй запрос дает ожидаемый результат, если вы не используете ProjectTo<> и используете ручную проекцию с .Select()? Если это так, не могли бы вы поделиться этим запросом ручной проекции?   -  person atiyar    schedule 19.02.2021
comment
Все это можно очень легко написать без AM, вот почему :) И тогда вы доберетесь до корня проблемы. Ты не все продумываешь!   -  person Lucian Bargaoanu    schedule 19.02.2021
comment
Проблема в том, что вы заставляете EF думать, что Status требуется, а это означает, что [c].StatusId не допускает значение NULL, а внутреннее соединение оправдано. Поэтому либо сделайте StatusId ненулевым, либо добавьте предикат, чтобы получить Custormers, у которых есть статус. Соединения никогда не должны использоваться в качестве скрытых предикатов.   -  person Gert Arnold    schedule 19.02.2021


Ответы (1)


Я протестировал ваш код в .NET Core 3.1 с Entity Framework Core 3.1 и AutoMapper 10.1.1. И -

  1. ваш первый запрос генерирует LEFT JOIN, а не INNER JOIN, как вы написали. Таким образом, результат этого запроса не будет исключать ни одного клиента с нулевым значением StatusId. И сгенерированный SQL такой же, как с ProjectTo<> и ручной проекцией EF. Я бы посоветовал еще раз проверить ваш запрос и сгенерировать SQL, чтобы убедиться.

  2. ваш второй запрос генерирует тот же SQL, который вы опубликовали, с ProjectTo<> и ручной проекцией EF.

Решение для вас:
Если я правильно понимаю, вы пытаетесь получить -

  1. список Customer в указанном диапазоне, у которых есть связанные Status
  2. количество всех таких клиентов в вашей базе данных.

Попробуйте следующее -

  1. Добавьте свойство внешнего ключа, допускающее значение NULL, в вашу модель Customer
public Guid? StatusId { get; set; }

Это поможет упростить ваши запросы и генерируемый ими SQL.

  1. Чтобы получить ожидаемый список, измените первый запрос как -
var viewModels = Db.Customers
                .Skip((1 - 1) * 25)
                .Take(25)
                .Where(p => p.StatusId != null)
                .ProjectTo<CustomerViewModel>(_Mapper.ConfigurationProvider)
                .ToList();

Он сгенерирует следующий SQL:

exec sp_executesql N'SELECT [t].[Code], [t].[Id], [t].[Name], [s].[Name] AS [StatusName], [t].[Website]
FROM (
    SELECT [c].[Id], [c].[Code], [c].[Name], [c].[StatusId], [c].[Website]
    FROM [Customers] AS [c]
    ORDER BY (SELECT 1)
    OFFSET @__p_0 ROWS FETCH NEXT @__p_1 ROWS ONLY
) AS [t]
LEFT JOIN [Statuses] AS [s] ON [t].[StatusId] = [s].[Id]
WHERE [t].[StatusId] IS NOT NULL',N'@__p_0 int,@__p_1 int',@__p_0=0,@__p_1=25
  1. Чтобы получить ожидаемое количество, измените второй запрос как -
var count = Db.Customers
            .Where(p => p.StatusId != null)
            .Count();

Он сгенерирует следующий SQL:

SELECT COUNT(*)
FROM [Customers] AS [c]
WHERE [c].[StatusId] IS NOT NULL
person atiyar    schedule 19.02.2021
comment
Вы все еще можете использовать ProjectTo, и тогда вам не понадобится Include. - person Lucian Bargaoanu; 19.02.2021
comment
@Atiyar, отличный практический пример, спасибо! и я согласен, что вы можете сделать то, что сделано там, то есть вручную исключить статус. Изучу это больше. Я думаю, что это скорее обходной путь, чем решение. - person Rtype; 20.02.2021
comment
После дальнейшего изучения этого вопроса выясняется, что это выбор дизайна командой EF. Спасибо @atiyar за то, что нашли время, чтобы предоставить решение. - person Rtype; 20.02.2021