Привязка GridView к LINQ to Entities: проекции, сортировка и постраничный вывод

РазделДанные и привязкаУровеньСредний Чтение8 минОпубликовано ТегиASP.NETGridViewLINQEntity Framework

Entity Framework позволяет отдать GridView результат LINQ-запроса вместо DataTable. Разбираем, как сделать это без утечек контекста, лишних колонок и выборки всей таблицы ради одной страницы.

Главное. GridView отлично работает с LINQ to Entities, если материализовать запрос внутри Using, выбирать только нужные колонки и отдавать пейджинг базе через OrderBy, Skip и Take.

От DataSet к сущностям

Классическая связка Web Forms выглядела так: SqlDataAdapter заполняет DataTable, DataTable уходит в GridView.DataSource. С появлением Entity Framework в .NET 3.5 SP1 и особенно в версии 4 данные стали приходить в виде объектов: Customer, Order, Product со свойствами и навигацией. GridView к этому готов, ему подходит любой IEnumerable.

Выгода не только в стиле кода. Запрос пишется на VB.NET с проверкой типов, опечатка в имени поля ловится компилятором, а не падает в рантайме на DataBinder.Eval. Но есть и новые ловушки: отложенное выполнение, время жизни контекста и соблазн выбрать всю таблицу. О них ниже.

Минимальная привязка

Простейший вариант: создать контекст, написать запрос и вызвать ToList() до привязки. ToList() здесь важен. LINQ-запрос выполняется лениво, и без материализации GridView начнёт читать данные уже после закрытия контекста, получив ObjectDisposedException.

Контекст оборачивается в Using: он держит соединение и кеш отслеживаемых объектов, и его нужно освобождать сразу после использования. Один контекст на всё приложение в статическом поле это классическая ошибка, которая приводит к росту памяти и устаревшим данным.

Customers.aspx.vbVB.NET
Protected Sub Page_Load(sender As Object, e As EventArgs) Handles Me.Load
    If Not IsPostBack Then BindGrid()
End Sub

Private Sub BindGrid()
    Using db As New NorthwindEntities()
        Dim query = From c In db.Customers
                    Where c.Country = "Germany"
                    Order By c.CompanyName
                    Select c

        ' материализуем до выхода из Using
        gvCustomers.DataSource = query.ToList()
        gvCustomers.DataBind()
    End Using
End Sub

Проекция: выбираем только нужные колонки

Запрос Select c тянет из базы все поля сущности, хотя таблица показывает три. Для широких таблиц с текстовыми полями это лишний трафик и память. Решение: проекция в анонимный тип или в небольшой класс-DTO.

Анонимный тип удобен для быстрой привязки, BoundField находит свойства по имени через отражение. DTO лучше, когда к строке нужно обращаться в коде, например в RowDataBound: его можно привести к типу, а анонимный объект нет. Ещё один плюс проекции: в неё можно включить поля связанных сущностей, и EF сам построит JOIN.

Customers.aspx.vbVB.NET
Public Class CustomerRow
    Public Property Id As String
    Public Property Company As String
    Public Property City As String
    Public Property OrdersCount As Integer
End Class

Using db As New NorthwindEntities()
    Dim rows = (From c In db.Customers
                Where c.Country = "Germany"
                Order By c.CompanyName
                Select New CustomerRow With {
                    .Id = c.CustomerID,
                    .Company = c.CompanyName,
                    .City = c.City,
                    .OrdersCount = c.Orders.Count() ' подзапрос в SQL
                }).ToList()
    gvCustomers.DataSource = rows
    gvCustomers.DataBind()
End Using
Агрегаты по связанным данным (количество, сумма заказов) считайте внутри запроса через Count или Sum. Если сначала вызвать ToList, а потом обращаться к c.Orders в цикле, ленивая загрузка выполнит отдельный SQL-запрос на каждую строку.

Постраничный вывод без выборки всей таблицы

Включить AllowPaging у GridView просто, но при привязке списка грид получает все строки и сам отрезает нужную страницу. На таблице в сто тысяч записей это означает сто тысяч объектов в памяти ради двадцати строк на экране.

Правильнее отдавать базе Skip и Take. Тогда GridView нужен режим пользовательского пейджинга: AllowCustomPaging и VirtualItemCount с общим числом записей. Важное требование LINQ to Entities: перед Skip обязательно должна стоять сортировка, иначе EF выбросит исключение.

Customers.aspx.vbVB.NET
Private Sub BindPage(pageIndex As Integer)
    Dim size As Integer = gvCustomers.PageSize
    Using db As New NorthwindEntities()
        Dim baseQuery = db.Customers.Where(Function(c) c.Country = "Germany")

        gvCustomers.VirtualItemCount = baseQuery.Count() ' всего записей
        gvCustomers.DataSource = baseQuery.
            OrderBy(Function(c) c.CompanyName). ' обязательно до Skip
            Skip(pageIndex * size).
            Take(size).
            Select(Function(c) New With {c.CustomerID, c.CompanyName, c.City}).
            ToList()
        gvCustomers.PageIndex = pageIndex
        gvCustomers.DataBind()
    End Using
End Sub

Protected Sub gvCustomers_PageIndexChanging(sender As Object,
        e As GridViewPageEventArgs) Handles gvCustomers.PageIndexChanging
    BindPage(e.NewPageIndex)
End Sub

Сортировка по клику на заголовок

При ручной привязке событие Sorting приходится обрабатывать самостоятельно. Направление сортировки удобно хранить во ViewState, а саму сортировку добавлять в запрос до Skip. Писать Select Case по именам колонок скучно, но это самый прозрачный и безопасный вариант: в запрос не попадёт ничего, кроме заранее разрешённых полей.

Для большого числа колонок пригодится библиотека System.Linq.Dynamic, позволяющая писать OrderBy("City DESC"). Если используете её, проверяйте выражение сортировки по белому списку: SortExpression приходит с клиента, и доверять ему нельзя.

  • храните текущие SortExpression и направление во ViewState;
  • при смене колонки сбрасывайте направление на возрастание, при повторном клике переключайте;
  • после сортировки возвращайте пользователя на первую страницу.

EntityDataSource или код

Web Forms предлагает и декларативный путь: EntityDataSource в разметке с указанием EntitySetName, Where и OrderBy. Он сам умеет пейджинг на стороне сервера и сортировку, и для простой админки это быстрый старт.

На практике контрол быстро становится тесным. Логику фильтров приходится писать строками Entity SQL, отладка неудобна, а повторно использовать запрос в другом месте нельзя. Для страниц, которые будут жить годами, код с явным запросом в отдельном методе или классе-репозитории обычно надёжнее. С .NET 4.5 появился и третий вариант: свойство SelectMethod у GridView, о нём в FAQ.

Как это делают сегодня

Сегодня тот же сценарий строят на EF Core и Blazor: QuickGrid принимает IQueryable через адаптер Microsoft.AspNetCore.Components.QuickGrid.EntityFrameworkAdapter и сам формирует Skip/Take и сортировку на стороне базы. Для API-слоя проекции пишут через Select в record-DTO, а для чтения добавляют AsNoTracking, чтобы не тратить память на отслеживание изменений.

Вопросы и ответы

Почему без ToList() появляется ObjectDisposedException?

LINQ-запрос выполняется только при переборе. GridView перебирает источник в DataBind, и если контекст к этому моменту уже закрыт, обращение к базе невозможно. ToList() внутри Using выполняет запрос заранее.

Что такое SelectMethod у GridView?

Это механизм Model Binding из ASP.NET 4.5: вы указываете имя метода, возвращающего IQueryable, а грид сам добавляет сортировку и Skip/Take. Пейджинг и сортировка тогда работают без ручных обработчиков.

Можно ли редактировать строки, если источник это анонимный тип?

Отображать можно, а для обновления придётся по ключу из DataKeys загрузить сущность в новом контексте, изменить свойства и вызвать SaveChanges. Анонимные объекты сами по себе только для чтения.

Читайте также