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.