1. Introducción
Imagínate el típico problema: tienes dos colecciones que están lógicamente relacionadas. Por ejemplo, una lista de categorías de productos y una lista de los propios productos. Hay que obtener para cada categoría todos los productos de esa categoría. O, por ejemplo, tienes una lista de departamentos de la empresa y una lista de empleados, y necesitas mostrar todos los empleados de cada departamento.
En SQL esto se llama "unión grupal" (GROUP JOIN o, más exactamente, LEFT OUTER JOIN con agrupación). En LINQ hay un operador especial para esto – GroupJoin. Es algo intermedio entre una unión normal (Join), donde a cada registro de la izquierda le corresponde exactamente uno de la derecha, y una agrupación por clave. GroupJoin conecta cada elemento de una colección con todos los elementos relacionados de la otra colección en forma de colección.
Analogía
Si el Join normal es como emparejar “padre e hijo” por apellido, entonces GroupJoin es como construir un árbol: a cada padre le pones la lista de todos sus hijos.
Esquemáticamente
Categorías Productos
+--------------+ +---------------------+
| Id | Nombre | | Nombre | CatId |
+----+---------+ +-----------+---------+
| 1 | Pan | ---> | Barra | 1 |
| 2 | Bebidas | | Embutido | 3 |
| 3 | Carne | | Pepsi | 2 |
| | | | Té | 2 |
+----+---------+ +-----------+---------+
Después de GroupJoin:
- Pan — [Barra]
- Bebidas — [Pepsi, Té]
- Carne — [Embutido]
2. Firma del método y conceptos básicos
Método de extensión
public static IEnumerable<TResult> GroupJoin<TOuter, TInner, TKey, TResult>(
this IEnumerable<TOuter> outer, // Colección "externa" (por ejemplo, categorías)
IEnumerable<TInner> inner, // Colección "interna" (por ejemplo, productos)
Func<TOuter, TKey> outerKeySelector, // Cómo obtener la clave del elemento externo
Func<TInner, TKey> innerKeySelector, // Cómo obtener la clave del elemento interno
Func<TOuter, IEnumerable<TInner>, TResult> resultSelector // Fábrica para crear el objeto/registro resultado
)
- outer: colección que se recorre y a la que se unen los elementos (por ejemplo, categorías).
- inner: colección de la que se seleccionan los elementos a unir (por ejemplo, productos).
- outerKeySelector: lambda que devuelve la clave para el elemento "izquierdo".
- innerKeySelector: lambda que devuelve la clave para el elemento "derecho".
- resultSelector: función que define cómo será el resultado para cada par (izquierdo+grupo de derechos).
3. Ejemplo práctico: categorías y productos
Supón que tenemos estos modelos:
public class Category
{
public int Id { get; set; }
public string Name { get; set; }
}
public class Product
{
public string Name { get; set; }
public int CategoryId { get; set; }
}
Colecciones para el ejemplo:
var categories = new List<Category>
{
new Category { Id = 1, Name = "Pan" },
new Category { Id = 2, Name = "Bebidas" },
new Category { Id = 3, Name = "Carne" }
};
var products = new List<Product>
{
new Product { Name = "Barra", CategoryId = 1 },
new Product { Name = "Pepsi", CategoryId = 2 },
new Product { Name = "Té", CategoryId = 2 },
new Product { Name = "Embutido", CategoryId = 3 }
};
Uso de GroupJoin (Method Syntax)
var groupJoin = categories.GroupJoin(
products,
category => category.Id, // clave de la categoría
product => product.CategoryId, // clave del producto
(category, prods) => new // construimos el resultado al vuelo
{
CategoryName = category.Name,
Products = prods.Select(p => p.Name).ToList() // lista de nombres de productos de esa categoría
}
);
Cómo recorrer el resultado:
foreach (var group in groupJoin)
{
Console.WriteLine($"Categoría: {group.CategoryName}");
foreach (var product in group.Products)
{
Console.WriteLine($" - {product}");
}
}
Salida:
Categoría: Pan
- Barra
Categoría: Bebidas
- Pepsi
- Té
Categoría: Carne
- Embutido
4. GroupJoin: Query Syntax (sintaxis de consulta)
LINQ soporta una sintaxis parecida a SQL. Para group join se usa la palabra clave join ... into ..., y esa consulta funciona casi igual que el ejemplo anterior.
var groupJoin2 = from c in categories
join p in products on c.Id equals p.CategoryId into prodGroup
select new
{
CategoryName = c.Name,
Products = prodGroup.Select(p => p.Name).ToList()
};
Esto se parece mucho a una consulta SQL con LEFT OUTER JOIN ... GROUP BY.
Esquema visual: cómo funciona GroupJoin
[Categoría] [Product] Agrupación (GroupJoin)
Pan --------> Barra => Pan: [Barra]
Bebidas --------> Pepsi => Bebidas: [Pepsi, Té]
Bebidas --------> Té
Carne --------> Embutido => Carne: [Embutido]
Cada categoría recibe su propio “bolsillo” (IEnumerable<Product>), donde caen todos los productos de esa categoría.
5. Particularidades y trampas
GroupJoin vs. Join normal
La diferencia entre un Join normal y un GroupJoin está en la cantidad de resultados. Join devuelve un par por cada coincidencia, y GroupJoin devuelve un elemento por cada elemento de la colección externa, dentro del cual hay una colección de todos los elementos coincidentes.
Si en nuestro esquema hay una categoría sin productos, con GroupJoin igual aparecerá, solo que su colección de productos estará vacía. Este comportamiento es igual que el LEFT OUTER JOIN en SQL (unión externa izquierda).
Aquí tienes un ejemplo con una categoría sin productos:
categories.Add(new Category { Id = 4, Name = "Quesos" });
var groupJoin3 = categories.GroupJoin(
products,
c => c.Id,
p => p.CategoryId,
(c, prods) => new
{
CategoryName = c.Name,
Products = prods.Select(p => p.Name).ToList()
});
foreach (var group in groupJoin3)
{
Console.WriteLine($"Categoría: {group.CategoryName}");
if (group.Products.Count == 0)
Console.WriteLine(" (Sin productos)");
else
foreach (var product in group.Products)
Console.WriteLine($" - {product}");
}
Categoría: Pan
- Barra
Categoría: Bebidas
- Pepsi
- Té
Categoría: Carne
- Embutido
Categoría: Quesos
(Sin productos)
Este escenario es muy común en aplicaciones de negocio: hay que mostrar todas las categorías (o grupos), aunque en alguna de ellas no haya elementos.
¿Implementar con GroupBy? ¡No!
Muchos se lían intentando “emular” GroupJoin usando doble GroupBy. No lo hagas — GroupJoin está hecho justo para esto y hace la “unión izquierda” de forma nativa.
6. Uso con datos reales
Además de las lecciones anteriores, vamos a añadir a nuestra app de prácticas la posibilidad de mostrar un informe: “Para cada categoría — la lista de sus productos”. Esta tarea es muy típica en tiendas online, CRM, sistemas de gestión o de informes.
Añadimos el código a nuestra demo:
// Supón que ya tienes las clases Category y Product y las colecciones creadas
Console.WriteLine("INFORME POR CATEGORÍAS Y PRODUCTOS:");
var categoryReport = categories.GroupJoin(
products,
cat => cat.Id,
prod => prod.CategoryId,
(cat, prods) => new
{
cat.Name,
ProductNames = prods.Select(p => p.Name).ToList()
});
foreach (var row in categoryReport)
{
Console.WriteLine($"Categoría: {row.Name}");
if (row.ProductNames.Count == 0)
Console.WriteLine(" (Sin productos)");
else
foreach (var prodName in row.ProductNames)
Console.WriteLine($" - {prodName}");
}
7. Agrupaciones anidadas y trabajo con agregados
GroupJoin se puede combinar con funciones agregadas para hacer informes más complejos.
Ejemplo: Contar la cantidad de productos en cada categoría
var reportWithCount = categories.GroupJoin(
products,
category => category.Id,
product => product.CategoryId,
(category, prods) => new
{
Category = category.Name,
Count = prods.Count() // ¡Función agregada!
});
foreach (var rec in reportWithCount)
{
Console.WriteLine($"{rec.Category}: {rec.Count} productos");
}
Supón que las colecciones categories y products tienen estos datos:
var categories = new[]
{
new { Id = 1, Name = "Frutas" },
new { Id = 2, Name = "Verduras" },
new { Id = 3, Name = "Lácteos" }
};
var products = new[]
{
new { Id = 1, Name = "Manzana", CategoryId = 1 },
new { Id = 2, Name = "Plátano", CategoryId = 1 },
new { Id = 3, Name = "Zanahoria", CategoryId = 2 }
};
La salida en consola será:
Categoría: Frutas — 2 producto(s)
Categoría: Verduras — 1 producto(s)
Categoría: Lácteos — 0 producto(s)
8. GroupJoin y errores típicos de principiantes
Un error común es esperar que el resultado de GroupJoin sea una tabla plana de pares, como en el Join normal. Por "plana" aquí se entiende una estructura donde cada fila es un par: un elemento externo y uno interno correspondiente (como una tabla SQL después de un INNER JOIN).
Esto confunde especialmente a los que han trabajado un poco con bases de datos: esperan que GroupJoin se comporte como un LEFT JOIN, pero devolviendo pares en filas, no grupos. Pero GroupJoin devuelve el elemento de la colección externa y una colección de los elementos internos relacionados — o sea, una estructura anidada.
No olvides "desplegar" las colecciones anidadas cuando lo necesites — por ejemplo, usando SelectMany si quieres una secuencia normal de pares.
Otro error típico es olvidar que para los elementos sin coincidencia el subgrupo será simplemente una lista vacía. Esto es el comportamiento por defecto, no un error — pero es importante recordarlo para no sorprenderte si en la salida "no pasa nada".
¿Cuándo usar GroupJoin y cuándo no?
Usa GroupJoin:
- Cuando tienes dos conjuntos de datos (por ejemplo, departamentos y empleados, categorías y productos) y necesitas mostrarlos jerárquicamente: para cada “padre” todos sus “hijos”.
- Para hacer informes complejos donde es importante mostrar todos los elementos principales aunque no tengan “hijos”.
- Cuando necesitas el equivalente SQL de LEFT OUTER JOIN con agrupación por clave.
No uses GroupJoin cuando solo necesitas cruzar colecciones o tener exactamente un par por coincidencia — para eso está el Join normal.
GO TO FULL VERSION