Ir al contenido principal

LINQ para consultas de bases de datos

LINQ (Language Integrated Query) no solo permite consultar colecciones en memoria, sino que también puede utilizarse para realizar consultas directamente a bases de datos. Esta capacidad convierte a LINQ en una herramienta extremadamente poderosa para el desarrollo de aplicaciones que requieren acceso a datos, ya que permite escribir consultas usando la sintaxis familiar de C# en lugar de SQL.

En el contexto de bases de datos, LINQ se materializa principalmente a través de Entity Framework Core, que actúa como un traductor que convierte las expresiones LINQ en consultas SQL optimizadas. Esta integración permite mantener la tipificación fuerte y el intellisense mientras se realizan operaciones complejas sobre datos almacenados.

En este artículo exploraremos cómo utilizar LINQ con Entity Framework Core para realizar consultas eficientes a bases de datos, desde las operaciones más básicas hasta técnicas avanzadas de optimización.

Configuración del contexto para LINQ

Para utilizar LINQ con bases de datos, necesitamos configurar adecuadamente nuestro contexto de Entity Framework Core. El contexto actúa como el puente entre nuestras entidades y la base de datos subyacente.

Definición de entidades relacionadas

Comencemos definiendo un conjunto de entidades que representen un sistema básico de gestión de productos y categorías:

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

public class Categoria
{
    public int Id { get; set; }
    
    [Required]
    [StringLength(100)]
    public string Nombre { get; set; }
    
    public string Descripcion { get; set; }
    
    // Propiedad de navegación
    public virtual ICollection<Producto> Productos { get; set; }
}

public class Producto
{
    public int Id { get; set; }
    
    [Required]
    [StringLength(200)]
    public string Nombre { get; set; }
    
    public string Descripcion { get; set; }
    
    [Column(TypeName = "decimal(18,2)")]
    public decimal Precio { get; set; }
    
    public int Stock { get; set; }
    
    public bool Activo { get; set; }
    
    public DateTime FechaCreacion { get; set; }
    
    // Clave foránea
    public int CategoriaId { get; set; }
    
    // Propiedad de navegación
    public virtual Categoria Categoria { get; set; }
}

public class Proveedor
{
    public int Id { get; set; }
    
    [Required]
    [StringLength(150)]
    public string Nombre { get; set; }
    
    public string Email { get; set; }
    
    public string Telefono { get; set; }
    
    public virtual ICollection<Producto> Productos { get; set; }
}

Configuración del DbContext

using Microsoft.EntityFrameworkCore;

public class TiendaContext : DbContext
{
    public DbSet<Producto> Productos { get; set; }
    public DbSet<Categoria> Categorias { get; set; }
    public DbSet<Proveedor> Proveedores { get; set; }
    
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseSqlite("Data Source=tienda.db");
        
        // Habilitar logging de consultas SQL (útil para desarrollo)
        optionsBuilder.LogTo(Console.WriteLine);
    }
    
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Configuración de la relación Categoria-Producto
        modelBuilder.Entity<Producto>()
            .HasOne(p => p.Categoria)
            .WithMany(c => c.Productos)
            .HasForeignKey(p => p.CategoriaId);
        
        // Configuración de índices
        modelBuilder.Entity<Producto>()
            .HasIndex(p => p.Nombre);
            
        modelBuilder.Entity<Producto>()
            .HasIndex(p => p.CategoriaId);
    }
}

Consultas básicas con LINQ

Las consultas LINQ sobre bases de datos utilizan la misma sintaxis que las consultas sobre colecciones en memoria, pero se ejecutan de forma diferida y se traducen a SQL.

Consultas de selección simples

using (var contexto = new TiendaContext())
{
    // Obtener todos los productos activos
    var productosActivos = await contexto.Productos
        .Where(p => p.Activo)
        .ToListAsync();
    
    Console.WriteLine($"Productos activos encontrados: {productosActivos.Count}");
    
    // Obtener productos por rango de precios
    var productosCaros = await contexto.Productos
        .Where(p => p.Precio >= 100m && p.Precio <= 500m)
        .OrderBy(p => p.Precio)
        .ToListAsync();
    
    foreach (var producto in productosCaros)
    {
        Console.WriteLine($"{producto.Nombre}: {producto.Precio:C}");
    }
    
    // Búsqueda por texto
    string termino = "laptop";
    var productosBuscados = await contexto.Productos
        .Where(p => p.Nombre.Contains(termino) || 
                   p.Descripcion.Contains(termino))
        .ToListAsync();
    
    Console.WriteLine($"Productos que contienen '{termino}': {productosBuscados.Count}");
}

Proyecciones y transformaciones

LINQ permite seleccionar solo los campos necesarios, lo que resulta en consultas SQL más eficientes:

using (var contexto = new TiendaContext())
{
    // Proyección a tipo anónimo
    var resumenProductos = await contexto.Productos
        .Where(p => p.Activo)
        .Select(p => new 
        {
            p.Id,
            p.Nombre,
            p.Precio,
            NombreCategoria = p.Categoria.Nombre
        })
        .ToListAsync();
    
    foreach (var item in resumenProductos)
    {
        Console.WriteLine($"{item.Nombre} - {item.NombreCategoria}: {item.Precio:C}");
    }
    
    // Proyección a lista de strings
    var nombresProductos = await contexto.Productos
        .Where(p => p.Stock > 0)
        .Select(p => p.Nombre)
        .ToListAsync();
    
    Console.WriteLine("Productos en stock:");
    nombresProductos.ForEach(nombre => Console.WriteLine($"- {nombre}"));
    
    // Cálculos en la proyección
    var estadisticasProductos = await contexto.Productos
        .Select(p => new
        {
            p.Nombre,
            ValorInventario = p.Precio * p.Stock,
            Estado = p.Stock > 0 ? "En Stock" : "Agotado"
        })
        .ToListAsync();
    
    foreach (var stat in estadisticasProductos)
    {
        Console.WriteLine($"{stat.Nombre}: {stat.ValorInventario:C} ({stat.Estado})");
    }
}

Consultas con JOIN y navegación

Una de las características más potentes de LINQ es su capacidad para manejar relaciones entre entidades de forma natural.

Joins implícitos usando propiedades de navegación

using (var contexto = new TiendaContext())
{
    // Join automático usando propiedad de navegación
    var productosConCategoria = await contexto.Productos
        .Include(p => p.Categoria)
        .Where(p => p.Activo)
        .Select(p => new
        {
            ProductoNombre = p.Nombre,
            CategoriaNombre = p.Categoria.Nombre,
            p.Precio
        })
        .ToListAsync();
    
    foreach (var item in productosConCategoria)
    {
        Console.WriteLine($"{item.ProductoNombre} [{item.CategoriaNombre}]: {item.Precio:C}");
    }
    
    // Filtrar por propiedades de entidades relacionadas
    var productosElectronicos = await contexto.Productos
        .Where(p => p.Categoria.Nombre == "Electrónicos" && p.Stock > 5)
        .Include(p => p.Categoria)
        .ToListAsync();
    
    Console.WriteLine($"Productos electrónicos con stock > 5: {productosElectronicos.Count}");
}

Joins explícitos

Para casos más complejos, podemos usar joins explícitos:

using (var contexto = new TiendaContext())
{
    // Join explícito entre productos y categorías
    var consultaJoin = await (from p in contexto.Productos
                             join c in contexto.Categorias on p.CategoriaId equals c.Id
                             where p.Precio > 50m
                             select new
                             {
                                 ProductoId = p.Id,
                                 ProductoNombre = p.Nombre,
                                 CategoriaNombre = c.Nombre,
                                 p.Precio,
                                 p.Stock
                             }).ToListAsync();
    
    foreach (var item in consultaJoin)
    {
        Console.WriteLine($"{item.ProductoNombre} - {item.CategoriaNombre}");
        Console.WriteLine($"  Precio: {item.Precio:C}, Stock: {item.Stock}");
    }
    
    // Left join usando DefaultIfEmpty
    var categoriasConProductos = await (from c in contexto.Categorias
                                       join p in contexto.Productos on c.Id equals p.CategoriaId into productos
                                       from producto in productos.DefaultIfEmpty()
                                       select new
                                       {
                                           CategoriaNombre = c.Nombre,
                                           ProductoNombre = producto != null ? producto.Nombre : "Sin productos",
                                           TieneProductos = producto != null
                                       }).ToListAsync();
    
    var resumenCategorias = categoriasConProductos
        .GroupBy(x => x.CategoriaNombre)
        .Select(g => new
        {
            Categoria = g.Key,
            TotalProductos = g.Count(x => x.TieneProductos),
            TieneProductos = g.Any(x => x.TieneProductos)
        });
    
    foreach (var resumen in resumenCategorias)
    {
        Console.WriteLine($"{resumen.Categoria}: {resumen.TotalProductos} productos");
    }
}

Agrupaciones y funciones de agregación

LINQ proporciona métodos poderosos para realizar agrupaciones y cálculos agregados directamente en la base de datos.

Operaciones de agrupación básicas

using (var contexto = new TiendaContext())
{
    // Agrupar productos por categoría
    var productosPorCategoria = await contexto.Productos
        .Include(p => p.Categoria)
        .GroupBy(p => p.Categoria.Nombre)
        .Select(g => new
        {
            Categoria = g.Key,
            TotalProductos = g.Count(),
            PromedioPrecios = g.Average(p => p.Precio),
            PrecioMinimo = g.Min(p => p.Precio),
            PrecioMaximo = g.Max(p => p.Precio),
            ValorTotalStock = g.Sum(p => p.Precio * p.Stock)
        })
        .OrderByDescending(x => x.TotalProductos)
        .ToListAsync();
    
    Console.WriteLine("Estadísticas por categoría:");
    Console.WriteLine(new string('-', 80));
    
    foreach (var grupo in productosPorCategoria)
    {
        Console.WriteLine($"Categoría: {grupo.Categoria}");
        Console.WriteLine($"  Total productos: {grupo.TotalProductos}");
        Console.WriteLine($"  Precio promedio: {grupo.PromedioPrecios:C}");
        Console.WriteLine($"  Rango precios: {grupo.PrecioMinimo:C} - {grupo.PrecioMaximo:C}");
        Console.WriteLine($"  Valor total stock: {grupo.ValorTotalStock:C}");
        Console.WriteLine();
    }
}

Agrupaciones múltiples y condicionales

using (var contexto = new TiendaContext())
{
    // Agrupar por múltiples criterios
    var estadisticasAvanzadas = await contexto.Productos
        .Include(p => p.Categoria)
        .GroupBy(p => new 
        { 
            CategoriaNombre = p.Categoria.Nombre,
            RangoPrecio = p.Precio < 50m ? "Económico" : 
                         p.Precio < 200m ? "Medio" : "Premium"
        })
        .Select(g => new
        {
            g.Key.CategoriaNombre,
            g.Key.RangoPrecio,
            Cantidad = g.Count(),
            StockTotal = g.Sum(p => p.Stock),
            ProductosActivos = g.Count(p => p.Activo)
        })
        .OrderBy(x => x.CategoriaNombre)
        .ThenBy(x => x.RangoPrecio)
        .ToListAsync();
    
    Console.WriteLine("Estadísticas por categoría y rango de precio:");
    Console.WriteLine(new string('-', 70));
    
    foreach (var stat in estadisticasAvanzadas)
    {
        Console.WriteLine($"{stat.CategoriaNombre} - {stat.RangoPrecio}:");
        Console.WriteLine($"  Productos: {stat.Cantidad} (Activos: {stat.ProductosActivos})");
        Console.WriteLine($"  Stock total: {stat.StockTotal}");
        Console.WriteLine();
    }
    
    // Agregaciones condicionales
    var resumenGeneral = await contexto.Productos
        .GroupBy(p => 1) // Agrupar todos los registros
        .Select(g => new
        {
            TotalProductos = g.Count(),
            ProductosActivos = g.Count(p => p.Activo),
            ProductosCaros = g.Count(p => p.Precio > 100m),
            ProductosConStock = g.Count(p => p.Stock > 0),
            ValorTotalInventario = g.Sum(p => p.Precio * p.Stock),
            PrecioPromedio = g.Average(p => p.Precio)
        })
        .FirstOrDefaultAsync();
    
    if (resumenGeneral != null)
    {
        Console.WriteLine("Resumen general del inventario:");
        Console.WriteLine($"Total productos: {resumenGeneral.TotalProductos}");
        Console.WriteLine($"Productos activos: {resumenGeneral.ProductosActivos}");
        Console.WriteLine($"Productos > 100€: {resumenGeneral.ProductosCaros}");
        Console.WriteLine($"Productos con stock: {resumenGeneral.ProductosConStock}");
        Console.WriteLine($"Valor total inventario: {resumenGeneral.ValorTotalInventario:C}");
        Console.WriteLine($"Precio promedio: {resumenGeneral.PrecioPromedio:C}");
    }
}

Consultas avanzadas y optimización

Para aplicaciones de producción, es crucial escribir consultas LINQ eficientes que se traduzcan en SQL optimizado.

Uso de Any, All y Contains para subconsultas

using (var contexto = new TiendaContext())
{
    // Usar Any para verificar existencia
    var categoriasConProductosActivos = await contexto.Categorias
        .Where(c => c.Productos.Any(p => p.Activo && p.Stock > 0))
        .Select(c => new
        {
            c.Nombre,
            ProductosActivos = c.Productos.Count(p => p.Activo),
            ProductosEnStock = c.Productos.Count(p => p.Stock > 0)
        })
        .ToListAsync();
    
    foreach (var categoria in categoriasConProductosActivos)
    {
        Console.WriteLine($"{categoria.Nombre}: {categoria.ProductosActivos} activos, {categoria.ProductosEnStock} en stock");
    }
    
    // Usar All para condiciones universales
    var categoriasCaras = await contexto.Categorias
        .Where(c => c.Productos.Any() && 
                   c.Productos.All(p => p.Precio > 50m))
        .Select(c => c.Nombre)
        .ToListAsync();
    
    Console.WriteLine("Categorías donde todos los productos cuestan más de 50€:");
    categoriasCaras.ForEach(nombre => Console.WriteLine($"- {nombre}"));
    
    // Usar Contains para consultas IN
    var categoriasSeleccionadas = new[] { "Electrónicos", "Ropa", "Hogar" };
    var productosCategoriasEspecificas = await contexto.Productos
        .Where(p => categoriasSeleccionadas.Contains(p.Categoria.Nombre))
        .Include(p => p.Categoria)
        .ToListAsync();
    
    Console.WriteLine($"Productos en categorías específicas: {productosCategoriasEspecificas.Count}");
}

Paginación eficiente

public class PaginacionHelper
{
    public static async Task<(List<T> Items, int TotalPaginas, int TotalRegistros)> 
        ObtenerPaginaAsync<T>(IQueryable<T> consulta, int pagina, int tamanoPagina)
    {
        var totalRegistros = await consulta.CountAsync();
        var totalPaginas = (int)Math.Ceiling(totalRegistros / (double)tamanoPagina);
        
        var items = await consulta
            .Skip((pagina - 1) * tamanoPagina)
            .Take(tamanoPagina)
            .ToListAsync();
        
        return (items, totalPaginas, totalRegistros);
    }
}

// Uso de la paginación
using (var contexto = new TiendaContext())
{
    var consultaBase = contexto.Productos
        .Include(p => p.Categoria)
        .Where(p => p.Activo)
        .OrderBy(p => p.Nombre);
    
    int paginaActual = 1;
    int tamanoPagina = 10;
    
    var (productos, totalPaginas, totalRegistros) = await PaginacionHelper
        .ObtenerPaginaAsync(consultaBase, paginaActual, tamanoPagina);
    
    Console.WriteLine($"Página {paginaActual} de {totalPaginas} (Total: {totalRegistros} productos)");
    Console.WriteLine(new string('-', 50));
    
    foreach (var producto in productos)
    {
        Console.WriteLine($"{producto.Nombre} - {producto.Categoria.Nombre}: {producto.Precio:C}");
    }
}

Consultas con CTE (Common Table Expressions) usando FromSql

using (var contexto = new TiendaContext())
{
    // Ejemplo de consulta SQL raw con LINQ
    var productosComplejos = await contexto.Productos
        .FromSqlRaw(@"
            WITH ProductosRankeados AS (
                SELECT p.*, 
                       ROW_NUMBER() OVER (PARTITION BY CategoriaId ORDER BY Precio DESC) as Ranking
                FROM Productos p 
                WHERE p.Activo = 1
            )
            SELECT * FROM ProductosRankeados 
            WHERE Ranking <= 3")
        .Include(p => p.Categoria)
        .ToListAsync();
    
    var productosPorCategoria = productosComplejos
        .GroupBy(p => p.Categoria.Nombre)
        .ToList();
    
    foreach (var grupo in productosPorCategoria)
    {
        Console.WriteLine($"Top productos en {grupo.Key}:");
        foreach (var producto in grupo.OrderByDescending(p => p.Precio))
        {
            Console.WriteLine($"  {producto.Nombre}: {producto.Precio:C}");
        }
        Console.WriteLine();
    }
}

Optimización de rendimiento

Técnicas de optimización para consultas LINQ

public class ServicioProductos
{
    private readonly TiendaContext _contexto;
    
    public ServicioProductos(TiendaContext contexto)
    {
        _contexto = contexto;
    }
    
    // Ejemplo de consulta optimizada con proyección
    public async Task<List<ProductoResumenDto>> ObtenerResumenProductosAsync(
        string filtroCategoria = null)
    {
        var consulta = _contexto.Productos.AsQueryable();
        
        if (!string.IsNullOrEmpty(filtroCategoria))
        {
            consulta = consulta.Where(p => p.Categoria.Nombre == filtroCategoria);
        }
        
        // Proyección directa para evitar cargar entidades completas
        return await consulta
            .Select(p => new ProductoResumenDto
            {
                Id = p.Id,
                Nombre = p.Nombre,
                Precio = p.Precio,
                Stock = p.Stock,
                CategoriaNombre = p.Categoria.Nombre,
                ValorStock = p.Precio * p.Stock
            })
            .AsNoTracking() // Importante para consultas de solo lectura
            .ToListAsync();
    }
    
    // Ejemplo de consulta con compilación
    private static readonly Func<TiendaContext, string, IAsyncEnumerable<Producto>> 
        ConsultaCompiladaProductosPorCategoria = 
        EF.CompileAsyncQuery((TiendaContext contexto, string categoriaNombre) =>
            contexto.Productos
                .Where(p => p.Categoria.Nombre == categoriaNombre && p.Activo)
                .Include(p => p.Categoria));
    
    public async Task<List<Producto>> ObtenerProductosPorCategoriaOptimizadoAsync(
        string categoriaNombre)
    {
        var productos = new List<Producto>();
        
        await foreach (var producto in ConsultaCompiladaProductosPorCategoria(_contexto, categoriaNombre))
        {
            productos.Add(producto);
        }
        
        return productos;
    }
}

public class ProductoResumenDto
{
    public int Id { get; set; }
    public string Nombre { get; set; }
    public decimal Precio { get; set; }
    public int Stock { get; set; }
    public string CategoriaNombre { get; set; }
    public decimal ValorStock { get; set; }
}

Monitoreo y debugging de consultas

public static class LinqDebuggingExtensions
{
    public static IQueryable<T> LogQuery<T>(this IQueryable<T> query, string descripcion = "")
    {
        Console.WriteLine($"[QUERY LOG] {descripcion}");
        Console.WriteLine($"SQL: {query.ToQueryString()}");
        Console.WriteLine(new string('-', 50));
        return query;
    }
}

// Uso del helper de debugging
using (var contexto = new TiendaContext())
{
    var productosCaros = await contexto.Productos
        .Where(p => p.Precio > 100m)
        .Include(p => p.Categoria)
        .LogQuery("Consulta productos caros")
        .ToListAsync();
    
    // También podemos examinar el SQL generado directamente
    var consulta = contexto.Productos
        .Where(p => p.Categoria.Nombre.StartsWith("Elec"))
        .Select(p => new { p.Nombre, p.Precio });
    
    string sqlGenerado = consulta.ToQueryString();
    Console.WriteLine("SQL Generado:");
    Console.WriteLine(sqlGenerado);
}

Resumen

LINQ para consultas de bases de datos representa una de las características más poderosas de C# y Entity Framework Core. Permite escribir consultas tipificadas y expresivas que se traducen automáticamente a SQL eficiente, manteniendo la productividad del desarrollador sin sacrificar el rendimiento.

Las técnicas fundamentales incluyen el uso de proyecciones para seleccionar solo los datos necesarios, la navegación por propiedades para manejar relaciones entre entidades, y las operaciones de agrupación para realizar cálculos agregados. Para aplicaciones de producción, es esencial considerar aspectos como la paginación, el uso de AsNoTracking() para consultas de solo lectura, y la compilación de consultas frecuentes. La capacidad de monitorear el SQL generado permite identificar oportunidades de optimización y asegurar que las consultas LINQ se traduzcan en código SQL eficiente.