0

EnityFramework: Usar First() o Single()

La elección entre los métodos First() y Single() puede tener un impacto significativo en la funcionalidad y rendimiento de nuestras consultas. Estos métodos, aparentemente similares, difieren en sus comportamientos y propósitos. En este artículo, exploramos a fondo las diferencias entre First() y Single() en Entity Framework, analizando sus casos de uso ideales y las consideraciones clave al seleccionar uno sobre el otro. Al entender cómo estas funciones interactúan con nuestras consultas a la base de datos, los desarrolladores podrán tomar decisiones informadas para optimizar sus operaciones y garantizar un código robusto y eficiente. ¡Acompáñanos en este viaje para descubrir cuál de estos métodos se adapta mejor a tus necesidades en el emocionante universo de Entity Framework!

Si no estás familiarizado con LINQ o C#, First() es una extensión de Enumerable<T> que te devuelve el primer elemento. Single() retorna únicamente el elemento que satisface la condición especificada y lanza una excepción si hay más de un elemento que coincide.

(Algunos) Han recomendado optar por First() porque es «más rápido». Pongo «más rápido» entre comillas porque esto depende de la implementación subyacente de First() que estés utilizando. Este es el dilema con las «mejores prácticas», ya que a menudo se presentan como afirmaciones generales sin tener en cuenta las compensaciones o comprender el contexto en el que podría ser algo a considerar.

Aquí te dejo un ejemplo en el que se utiliza Entity Framework Core para realizar una consulta en una base de datos.

var order = await _context.Orders
    .Where(x => x.OrderId == orderId)
    .Select(x => new OrderResponse
    {
        OrderDate = x.OrderDate,
        OrderNumber = x.OrderNumber,
        ShippingAddress = x.ShippingAddress,
        Total = x.Total()
    })
    .FirstOrDefaultAsync();

En este ejemplo, la clave principal es OrderId. La «estrategia recomendada» es emplear First(OrDefault) porque es «más eficiente». ¿Por qué? Porque First() solo necesita coincidir con el primer elemento y no requiere realizar cálculos para determinar si hay más de un elemento coincidente. Esto tendría sentido si estuvieras utilizando First/Single en una colección en memoria, pero no es así. Esto va en contra de una base de datos y es un aspecto crucial.

He llevado a cabo algunas pruebas para exponer el SQL generado y el tiempo de ejecución de las comparaciones. En primer lugar, te presento un TestDbContext con EF Core y estoy sorprendido por el SQL que se genera en la consola.

public class TestDbContext : DbContext
{
    public DbSet<Order> Orders => Set<Order>();

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        var connectionString = "Server=localhost;Port=3307;Database=Demo;Uid=root;Pwd=root";
        optionsBuilder.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString));

        optionsBuilder.LogTo(Console.WriteLine);
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Order>().HasKey(x => x.OrderId);
    }
}

public class Order
{
    [Key]
    public int OrderId { get; set; }
    
    public DateTime Date { get; set; }
    public int CustomerId { get; set; }
}

Aquí te expongo dos escenarios para detallar las diferencias entre Single() y First().

public class Test
{
    [Fact]
    public async Task Single()
    {
        var db = new TestDbContext();
        await db.Orders.SingleAsync(x => x.OrderId == 1000);
    }
    
    [Fact]
    public async Task First()
    {
        var db = new TestDbContext();
        await db.Orders.FirstAsync(x => x.OrderId == 1000);
    }

El código resultante:

SELECT ‘o’.’OrderId’, ‘o’.’CustromerId’, ‘o’.’Date’
FROM ‘Orders’ AS ‘o’
WHERE ‘o’.’OrderId’ = 1000
LIMIT 2

Se destaca la inclusión del LÍMITE 2, indicando que si se obtienen más de un registro, se producirá una excepción debido a la presencia de múltiples registros coincidentes.

SELECT ‘o’.’OrderId’, ‘o’.’CustromerId’, ‘o’.’Date’
FROM ‘Orders’ AS ‘o’
WHERE ‘o’.’OrderId’ = 1000
LIMIT 1

La implementación de la variante U de EF Core está optimizada para recuperar como máximo 2 registros de la base de datos al utilizar Single(). En contraste, al emplear First(), la consulta devuelve de manera específica solo 1 registro de la base de datos. En consecuencia, la distinción entre ambas implementaciones en términos de rendimiento se reduce a la recuperación de 1 o, como máximo, 2 registros.

using BenchmarkDotNet.Attributes;
using Microsoft.EntityFrameworkCore;

namespace FirstSingle;

[SimpleJob]
public class BenchmarkDb
{
    private readonly TestDbContext _db;
    private readonly int _orderId;

    public BenchmarkDb()
    {
        _db = new TestDbContext();
        var rnd = new Random();
        _orderId  = rnd.Next(1, 1_000_000);
    }

    [Benchmark]
    public async Task Single() => await _db.Orders.SingleAsync(x => x.OrderId == _orderId);
    
    [Benchmark]
    public async Task First() => await _db.Orders.FirstAsync(x => x.OrderId == _orderId);

}

Veamos la salida:

¿Es First() la opción más rápida? Sí, lo es, aunque la diferencia sea apenas perceptible. Pero, ¿vale la pena el compromiso? Cada elección conlleva sus propias consecuencias. La llamada a First() está incrustada directamente en el código con la instrucción ‘dame el primer registro’. Sin embargo, ¿y si hay más de un registro? Nada, nunca lo advertirías. First() encubre un posible problema de datos. Por otro lado, Single() es claro en el código, especificando ‘debería existir solo un elemento/registro en esta colección que estoy intentando encontrar’. Si hay más de uno, se activa una bomba/excepción para revelar problemas de datos.

La implementación de First() en Enumerable<T> difiere por completo de la que utiliza EF Core. Si se encuentra manejando una colección extensa en memoria y busca maximizar el rendimiento para un escenario específico, podría tener sentido llamar a First(), incluso si tiene conocimiento de que debería existir solo un elemento. No obstante, la ‘mejor práctica’ o regla general de optar por First() en lugar de Single() carece de fundamento. Comprender la implementación de esa abstracción es esencial. La manera en que se comporta en un Enumerable<T> es notablemente diferente de su comportamiento en un IQueryable<T> de EF Core.

Conclusiones

En resumen, la elección entre First() y Single() no se reduce simplemente a la velocidad de ejecución, ya que ambos métodos tienen sus propias compensaciones. Mientras First() puede ofrecer un ligero aumento en el rendimiento, su uso indiscriminado puede ocultar problemas potenciales de datos al no indicar explícitamente la expectativa de un solo elemento. Por otro lado, Single() proporciona una claridad conceptual al especificar que se espera un único elemento y expone cualquier desviación mediante una excepción. La supuesta «mejor práctica» de utilizar First() sobre Single() carece de fundamento, ya que la implementación varía según el contexto, como en el caso de Enumerable<T> frente a IQueryable<T> en EF Core. La comprensión profunda de estas abstracciones y sus implementaciones permitirá tomar decisiones informadas, considerando las compensaciones específicas de cada escenario.

Fernando Sonego

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *