DEV Community

Isaac Ojeda
Isaac Ojeda

Posted on

[Parte 14] Entity Framework Core - Paginación

Introducción

En el post de hoy veremos algo muy sencillo pero muy útil a la hora de hacer Queries en Entity Framework: La paginación.

La paginación de la información realmente es algo necesario cuando hablamos de información frecuentemente accedida y que con el paso del tiempo va creciendo.

Según el motor de base de datos que utilices esta operación se puede llevar a cabo de distintas formas, pero la gran mayoría utiliza un motor SQL, esto debería de ser igual para todos y más si usamos un ORM como Entity Framework Core.

Nota 💡: El código fuente siempre lo encuentras en mi perfil: isaacOjeda/AspNetCoreMediatRExample at parte-14 (github.com)

Implementando Paginación

Realizar esta tarea es muy sencillo, empezaremos creando una clase que envolverá los resultados paginados:

namespace MediatrExample.ApplicationCore.Common.Models;

public class PagedResult<T>
    where T: class
{
    public IEnumerable<T> Results { get; set; } = Enumerable.Empty<T>();
    public int RowsCount { get; set; }
    public int PageCount { get; set; }
    public int PageSize { get; set; }
    public int CurrentPage { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

Es útil saber lo siguiente:

  • RowsCount: Tendrá cuantos resultados tiene el query antes de ser paginado
  • PageCount: Nos dice cuántas páginas se pueden visitar según el query, esto es útil para tener en el UI un paginador y poder decir "Página 1 de 130"
  • PageSize: Aunque este dato viene desde la solicitud, de igual forma me ha resultado útil regresarlo aquí, este no es obligatorio
  • CurrentPage: La página actual que se solicitó por lo que Results vendrán los resultados de la página actual

Hacemos esta clase genérica para poderla utilizar con cualquier DTO y no tener que estar implementándolo en cada caso en particular.

Para seguir, crearemos un método de extensión para hacerlo más fácil al hacer los queries:

public static class EFCoreExtensions
{
    public static async Task<PagedResult<TEntity>> GetPagedResultAsync<TEntity>(this IQueryable<TEntity> source, int pageSize, int currentPage)
        where TEntity: class
    {
        var rows = source.Count();
        var results = await source
            .Skip(pageSize * (currentPage - 1))
            .Take(pageSize)
            .ToListAsync();

        return new PagedResult<TEntity>
        {
            CurrentPage = currentPage,
            PageCount = (int)Math.Ceiling((double)rows / pageSize),
            PageSize = pageSize,
            Results = results,
            RowsCount = rows
        };
    }

   // código omitido...
}
Enter fullscreen mode Exit fullscreen mode

Aquí estamos realizando dos queries, es necesario para saber cuántos registros son en total antes de paginar, este se puede cachear de alguna forma para evitar hacerlo siempre, pero por ahora lo dejamos así.

Lo que hace la paginación es el Skip que se brinca los "n" registros que se le indique y Take agarra los "n" registros restantes que quedan después de haber "skipeado". O sea, la paginación funciona de la siguiente manera;

Si pageSize es 10 y currentPage es 5 el query tendrá un Skip de 40 registros y siempre tendrá un Take de 10 ya que serán los elementos que queremos mostrar por página. Una forma de visualizarlo:

Image description

Los elementos en gris fueron skipeados (no se muestran todos, pero digamos que van del 1 al 40) y los elementos en verde fueron tomados con el Take para ser consultados, los que no tienen fondo, son elementos de la siguiente página, ya que solo estamos consultando de la base de datos de 10 en 10 (pero claro puedes elegir el tamaño de la página como lo desees).

Para utilizarlo, nos vamos al Query original que ya teníamos y hacemos uso del nuevo método de extensión que acabamos de agregar:

public class GetProductsQuery : IRequest<PagedResult<GetProductsQueryResponse>>
{
    public string? SortDir { get; set; }
    public string? SortProperty { get; set; }

    public int CurrentPage { get; set; }   // NEW!!
    public int PageSize { get; set; }      // NEW!!
}

public class GetProductsQueryHandler : IRequestHandler<GetProductsQuery, PagedResult<GetProductsQueryResponse>>
{
    private readonly MyAppDbContext _context;
    private readonly IMapper _mapper;

    public GetProductsQueryHandler(MyAppDbContext context, IMapper mapper)
    {
        _context = context;
        _mapper = mapper;
    }

    public Task<PagedResult<GetProductsQueryResponse>> Handle(GetProductsQuery request, CancellationToken cancellationToken) =>
        _context.Products
            .AsNoTracking()
            .OrderBy($"{request.SortProperty} {request.SortDir}")
            .ProjectTo<GetProductsQueryResponse>(_mapper.ConfigurationProvider)
            .GetPagedResultAsync(request.PageSize, request.CurrentPage);  // NEW!!
}
Enter fullscreen mode Exit fullscreen mode

CurrentPage y PageSize vendrán del cliente, ya que él es el que decide el tamaño de las páginas (pero por supuesto no es mala idea validar que siempre se haga la paginación y no de un tamaño tan exagerado, si el rendimiento es lo que estamos cuidando al hacer la paginación, también debemos de considerar eso).

El método GetPagedResultAsync es el que mandamos a llamar al final para terminar con el query (ya que podemos tener filtros, selects y otras cosas) y por lo tanto ahora nuestro query ahora regresa un PagedResult y no una colección de productos directamente.

Nota 💡: Es importante que, al hacer paginación, siempre debe de existir un OrderBy ya que si no se hace eso podrían obtenerse resultados aleatorios en la paginación

También actualizamos el Controller para regresar un PagedResult:

    /// <summary>
    /// Consulta los productos
    /// </summary>
    /// <returns></returns>
    [HttpGet]
    public Task<PagedResult<GetProductsQueryResponse>> GetProducts([FromQuery] GetProductsQuery query) =>
        _mediator.Send(query);
Enter fullscreen mode Exit fullscreen mode

Y si hacemos pruebas con cualquier cliente HTTP:

### GET Products
GET {{host}}/api/products?pageSize=5&currentPage=3&sortDir=desc&sortProperty=price
Content-Type: application/json
Authorization: Bearer {{token}}
Enter fullscreen mode Exit fullscreen mode

Nota 💡: El ordenamiento lo vimos en este post por si te interesa

Y el resultado:

{
  "results": [
    {
      "productId": "JqpBAvow7oYVkaDb",
      "description": "Product 10",
      "price": 892,
      "listDescription": "Product 10 - $892.00"
    },
    {
      "productId": "eQPDkwoYXg31vMKG",
      "description": "Product 100",
      "price": 889,
      "listDescription": "Product 100 - $889.00"
    },
    {
      "productId": "Qz87dMy6n0ymlkX1",
      "description": "Product 93",
      "price": 875,
      "listDescription": "Product 93 - $875.00"
    },
    {
      "productId": "jNDdgVoEPD361nQ8",
      "description": "Product 92",
      "price": 855,
      "listDescription": "Product 92 - $855.00"
    },
    {
      "productId": "24gZjVOlrV3KELa6",
      "description": "Product 42",
      "price": 850,
      "listDescription": "Product 42 - $850.00"
    }
  ],
  "rowsCount": 100,
  "pageCount": 20,
  "pageSize": 5,
  "currentPage": 3
}
Enter fullscreen mode Exit fullscreen mode

Nota 💡: Siempre revisa el código fuente (en isaacOjeda/AspNetCoreMediatRExample at parte-14 (github.com)) ya que en este ejemplo actualicé el método Seed para tener muchos productos de prueba en la base de datos

Conclusión

Siempre debemos de incluir la páginación del lado del servidor, ya que hacerlo en el front-end aunque a veces es práctico, en un algún momento existirán problemas de rendimiento. Traer todo de un solo golpe de la base de datos puede ser costoso en tiempo de CPU y RAM en tu aplicación, por eso siempre te recomiendo que desde un inicio busques implementarlo en esos lugares donde estás seguro de que la información crecerá con el paso del tiempo.

Por ejemplo, cuando tenemos catálogos de configuración del sistema, donde si mucho existirán menos de 100 registros, tal vez ahí sí decidiría no implementar la paginación, ya que son listados poco accedidos y con poca información, pero cuando hablamos de listados frecuentemente consultados, es obligatorio implementar la paginación, ya que si no lo haces seguro te enfrentarás con problemas de rendimiento.

Top comments (2)

Collapse
 
edd profile image
NSysX

Hola, Issac, he visto que en esto hay polemica de si deben de implementarse en los headers o en el body. Tal vez puedas dar tu opinion de por que los usas en body.
que opinas de esta forma que usan en el body
{
"_metadata":
{
"page": 5,
"per_page": 20,
"page_count": 20,
"total_count": 521,
"Links": [
{"self": "/products?page=5&per_page=20"},
{"first": "/products?page=0&per_page=20"},
{"previous": "/products?page=4&per_page=20"},
{"next": "/products?page=6&per_page=20"},
{"last": "/products?page=26&per_page=20"},
]
},
"Los recods ": [....]

Es muy exagerado ?

Gracias !!!

Collapse
 
isaacojeda profile image
Isaac Ojeda

Hola!

Siendo sincero nunca había visto que se mandara esa información en los headers. No le veo ningún problema que se haga de una forma u otra, pero prefiero regresarlo como parte de la respuesta en JSON ya que en el frontend es mucho más sencillo de leer esa información.

Y por como pones tu ejemplo, si así te funciona, está bien, el frontend es el que se encargará de interpretar eso, si así se te hace buena opción, está bien.

La forma en la que yo manejo la paginación en el front, con la info que estoy mandando, me es suficiente para crear el "Paginador".

Igual, aquí está esta guía de Microsoft para diseño de APIs

Saludos!