C# LINQ: Juntar

Na lojinha da Ana, os clientes ficam numa lista e os pedidos em outra. O pedido não guarda o nome do cliente, só o número dele (o ClienteId). Para escrever "Ana pagou 45,90", o programa precisa juntar as duas listas: para cada pedido, achar o cliente que tem aquele número. O LINQ faz isso com Join, GroupJoin e, no .NET 10, LeftJoin e RightJoin. E quando as listas combinam pela posição (o 1º nome com a 1ª nota), quem resolve é o Zip.

Você vai aprender
  • O que é uma chave e como o Join casa os itens de duas listas por ela.
  • O mesmo Join na sintaxe de consulta: join … in … on … equals …, e por que a ordem dos lados importa (erros CS1937 e CS1938).
  • O GroupJoin (e o join … into): cada cliente com a lista dos seus pedidos, inclusive quem não tem nenhum.
  • O LeftJoin e o RightJoin .NET 10, e o jeito antigo com DefaultIfEmpty.
  • Chaves compostas (duas informações ao mesmo tempo) e o Zip, que junta pela posição.

Esta aula usa o que você viu em O que é LINQ, Where e Select e Count e Sum, além dos records e das lambdas (c => c.Id). Os exemplos rodaram com dotnet run no .NET 10 da máquina da Ana, na pasta ~/cs.


A ideia antes do código

Pense numa pilha de comandas de lanchonete. Cada comanda diz "mesa 3: dois sucos". Num caderno à parte, o garçom anotou "mesa 3: Ana". Para saber o que a Ana pediu, ele pega cada comanda, olha o número da mesa e procura esse número no caderno. O número da mesa é a chave: a informação que aparece nas duas listas e liga uma à outra.

Juntar (em inglês, join) é exatamente isso: para cada item de uma lista, achar os itens da outra lista que têm a mesma chave, e montar um resultado com as duas partes. Bancos de dados fazem isso o tempo todo (o JOIN do SQL), e o LINQ faz o mesmo com as listas do seu programa.

Em toda a aula, os dados são estes dois records. Repare que o pedido 104 é de um cliente número 9, que não existe, e que a Carla (número 3) não fez pedido nenhum. Esses dois "sobrando" vão mostrar a diferença entre cada tipo de junção.

record Cliente(int Id, string Nome);
record Pedido(int Numero, int ClienteId, decimal Valor);

// clientes: 1 Ana, 2 Bruno, 3 Carla
// pedidos:  101 (cliente 1, 45,90)  102 (cliente 2, 12,50)
//           103 (cliente 1, 30,00)  104 (cliente 9, 99,00)

1. Join: casar pela chave

O método Join (juntar) recebe a outra lista e três lambdas: como achar a chave de um lado, como achar a chave do outro e o que montar com cada par que casou.

List<Cliente> clientes =
[
    new(1, "Ana"),
    new(2, "Bruno"),
    new(3, "Carla"),
];

List<Pedido> pedidos =
[
    new(101, 1, 45.90m),
    new(102, 2, 12.50m),
    new(103, 1, 30.00m),
    new(104, 9, 99.00m),
];

var juntos = clientes.Join(
    pedidos,
    c => c.Id,
    p => p.ClienteId,
    (c, p) => $"Pedido {p.Numero}: {c.Nome} pagou {p.Valor}");

foreach (var linha in juntos)
{
    Console.WriteLine(linha);
}

record Cliente(int Id, string Nome);
record Pedido(int Numero, int ClienteId, decimal Valor);
$ dotnet run join1.cs
Pedido 101: Ana pagou 45,90
Pedido 103: Ana pagou 30,00
Pedido 102: Bruno pagou 12,50
clientes.Join(
A lista "de fora" (em inglês, outer). O resultado segue a ordem dela: primeiro tudo da Ana, depois do Bruno.
pedidos,
A lista "de dentro" (inner), a que vai ser procurada.
c => c.Id,
Seletor da chave de fora: "a chave de um cliente é o Id dele".
p => p.ClienteId,
Seletor da chave de dentro: "a chave de um pedido é o ClienteId". As duas chaves têm de ser do mesmo tipo (aqui, int).
(c, p) => …
O seletor do resultado: recebe um cliente e um pedido que casaram e devolve o que você quiser. Aqui, um texto.

Olhe o que não apareceu:

  • A Carla não tem pedido, então não formou par nenhum e sumiu.
  • O pedido 104 aponta para o cliente 9, que não existe; também sumiu.

Esse comportamento tem nome: junção interna (inner join). Só entra no resultado quem tem par dos dois lados. Na seção 3 você vai ver como manter quem ficou sem par.

A ordem do resultado

O Join percorre a lista de fora na ordem dela e, para cada item, devolve os pares na ordem em que aparecem na lista de dentro. Por isso o pedido 103 veio antes do 102: os dois da Ana vêm juntos, porque a Ana é a primeira cliente. Quer outra ordem? Use OrderBy depois.

A mesma coisa na sintaxe de consulta

O LINQ tem uma segunda forma de escrever, parecida com SQL (a "sintaxe de consulta", que você conheceu em O que é LINQ). Nela, a junção usa as palavras join, on e equals:

List<Cliente> clientes = [new(1, "Ana"), new(2, "Bruno"), new(3, "Carla")];
List<Pedido> pedidos = [new(101, 1, 45.90m), new(102, 2, 12.50m), new(103, 1, 30.00m), new(104, 9, 99.00m)];

var juntos =
    from c in clientes
    join p in pedidos on c.Id equals p.ClienteId
    select new { c.Nome, p.Numero, p.Valor };

foreach (var item in juntos)
{
    Console.WriteLine(item);
}

record Cliente(int Id, string Nome);
record Pedido(int Numero, int ClienteId, decimal Valor);
$ dotnet run join2.cs
{ Nome = Ana, Numero = 101, Valor = 45,90 }
{ Nome = Ana, Numero = 103, Valor = 30,00 }
{ Nome = Bruno, Numero = 102, Valor = 12,50 }
LinhaO que faz
from c in clientes"Para cada cliente c": a lista de fora.
join p in pedidos"Junte com cada pedido p": a lista de dentro.
on c.Id equals p.ClienteIdA condição: a chave do cliente é igual à chave do pedido. À esquerda do equals fica a lista de fora; à direita, a de dentro.
select new { c.Nome, p.Numero, p.Valor }Monta um tipo anônimo (um objeto sem nome de classe) com três campos. O WriteLine mostra os campos entre chaves.

As duas formas geram o mesmo resultado; o compilador transforma a de consulta numa chamada a Join. Muita gente acha a de consulta mais fácil de ler quando há junções, porque o on … equals … fica parecido com a frase em português.

É equals, não ==

Dentro do join, a comparação é sempre a palavra equals ("é igual a"). O == não é aceito ali, e a ordem dos lados é fixa. Os dois erros estão em "Quando dá errado".

Missão: a lista vendas só tem o número do produto. Troque o laço por um Join (ou join … equals) com produtos para mostrar o nome, a quantidade e o total de cada venda: Caneta x 10 = 25,00.

List<Produto> produtos = [new(1, "Caderno", 15.00m), new(2, "Caneta", 2.50m), new(3, "Borracha", 1.20m)];
List<Venda> vendas = [new(2, 10), new(1, 2), new(2, 4)];

// Troque este laço por um Join que mostre o NOME do produto
foreach (var v in vendas)
{
    Console.WriteLine($"Produto {v.ProdutoId} x {v.Quantidade}");
}

record Produto(int Id, string Nome, decimal Preco);
record Venda(int ProdutoId, int Quantidade);
List<Produto> produtos = [new(1, "Caderno", 15.00m), new(2, "Caneta", 2.50m), new(3, "Borracha", 1.20m)];
List<Venda> vendas = [new(2, 10), new(1, 2), new(2, 4)];

var linhas = vendas.Join(
    produtos,
    v => v.ProdutoId,
    p => p.Id,
    (v, p) => $"{p.Nome} x {v.Quantidade} = {p.Preco * v.Quantidade}");

foreach (var linha in linhas)
{
    Console.WriteLine(linha);
}

record Produto(int Id, string Nome, decimal Preco);
record Venda(int ProdutoId, int Quantidade);

Na solução, a lista de fora é vendas, e por isso o resultado sai na ordem das vendas (Caneta, Caderno, Caneta):

$ dotnet run vendas.cs
Caneta x 10 = 25,00
Caderno x 2 = 30,00
Caneta x 4 = 10,00

2. GroupJoin: cada um com a sua lista

Às vezes você não quer um par por linha, e sim cada cliente com todos os pedidos dele num pacote só: "Ana: 2 pedidos, total 75,90". É o GroupJoin (juntar agrupando). A diferença está no último lambda: em vez de um pedido, ele recebe a coleção dos pedidos daquele cliente.

List<Cliente> clientes = [new(1, "Ana"), new(2, "Bruno"), new(3, "Carla")];
List<Pedido> pedidos = [new(101, 1, 45.90m), new(102, 2, 12.50m), new(103, 1, 30.00m), new(104, 9, 99.00m)];

var porCliente = clientes.GroupJoin(
    pedidos,
    c => c.Id,
    p => p.ClienteId,
    (c, seus) => new { c.Nome, Quantos = seus.Count(), Total = seus.Sum(p => p.Valor) });

foreach (var r in porCliente)
{
    Console.WriteLine($"{r.Nome}: {r.Quantos} pedido(s), total {r.Total}");
}

Console.WriteLine("--- com into ---");
var consulta =
    from c in clientes
    join p in pedidos on c.Id equals p.ClienteId into seus
    select new { c.Nome, Numeros = string.Join(", ", seus.Select(p => p.Numero)) };
foreach (var r in consulta)
{
    Console.WriteLine($"{r.Nome}: [{r.Numeros}]");
}

record Cliente(int Id, string Nome);
record Pedido(int Numero, int ClienteId, decimal Valor);
$ dotnet run grupo.cs
Ana: 2 pedido(s), total 75,90
Bruno: 1 pedido(s), total 12,50
Carla: 0 pedido(s), total 0
--- com into ---
Ana: [101, 103]
Bruno: [102]
Carla: []
clientes.GroupJoin(
Mesma ideia do Join: a lista de fora é a dos clientes.
pedidos, c => c.Id, p => p.ClienteId,
A lista de dentro e as duas chaves, iguaizinhas às do Join.
(c, seus) =>
A diferença: seus é um IEnumerable<Pedido>, a coleção de todos os pedidos do cliente c. Pode estar vazia.
seus.Count(), seus.Sum(p => p.Valor)
Como seus é uma coleção, dá para usar nela qualquer operador do LINQ, como os de agregar.
join … into seus
Na sintaxe de consulta, o into ("para dentro de") transforma o join num GroupJoin: os pedidos de cada cliente vão para dentro de seus.

Repare na Carla: desta vez ela aparece, com 0 pedidos e total 0. O GroupJoin devolve um resultado para cada item da lista de fora, tenha ele par ou não. Já o pedido 104 (do cliente 9) continua de fora, porque nenhum cliente o "puxou".

GroupJoin × GroupBy

O GroupBy agrupa uma lista pelo valor de uma chave. O GroupJoin agrupa a segunda lista em torno de cada item da primeira. Com pedidos.GroupBy(p => p.ClienteId), a Carla não apareceria (ela não tem pedido) e o cliente 9 apareceria (ele tem). Com o GroupJoin a partir de clientes, é o contrário.


3. Manter quem ficou sem par: LeftJoin e RightJoin .NET 10

E se você quer uma linha por pedido, como no Join, mas sem perder a Carla? Isso é a junção à esquerda (left join): todo item da lista da esquerda (a de fora) aparece; quando não há par, o outro lado vem como null. Até o .NET 9 isso exigia uma receita de três passos. O .NET 10 ganhou os métodos LeftJoin e RightJoin:

List<Cliente> clientes = [new(1, "Ana"), new(2, "Bruno"), new(3, "Carla")];
List<Pedido> pedidos = [new(101, 1, 45.90m), new(102, 2, 12.50m), new(103, 1, 30.00m), new(104, 9, 99.00m)];

// LeftJoin (.NET 10)
var esquerda = clientes.LeftJoin(
    pedidos,
    c => c.Id,
    p => p.ClienteId,
    (c, p) => $"{c.Nome} -> {(p is null ? "sem pedido" : p.Numero.ToString())}");
foreach (var l in esquerda) Console.WriteLine(l);

Console.WriteLine("--- jeito antigo ---");
var antigo =
    from c in clientes
    join p in pedidos on c.Id equals p.ClienteId into seus
    from p in seus.DefaultIfEmpty()
    select $"{c.Nome} -> {(p is null ? "sem pedido" : p.Numero.ToString())}";
foreach (var l in antigo) Console.WriteLine(l);

Console.WriteLine("--- RightJoin ---");
var direita = clientes.RightJoin(
    pedidos,
    c => c.Id,
    p => p.ClienteId,
    (c, p) => $"Pedido {p.Numero} -> {c?.Nome ?? "cliente desconhecido"}");
foreach (var l in direita) Console.WriteLine(l);

record Cliente(int Id, string Nome);
record Pedido(int Numero, int ClienteId, decimal Valor);
$ dotnet run esquerda.cs
Ana -> 101
Ana -> 103
Bruno -> 102
Carla -> sem pedido
--- jeito antigo ---
Ana -> 101
Ana -> 103
Bruno -> 102
Carla -> sem pedido
--- RightJoin ---
Pedido 101 -> Ana
Pedido 102 -> Bruno
Pedido 103 -> Ana
Pedido 104 -> cliente desconhecido
TrechoO que faz
clientes.LeftJoin(pedidos, …)Todo cliente aparece. Quem não tem pedido aparece uma vez, com p valendo null.
p is null ? "sem pedido" : …Por isso o seletor precisa tratar o null. Com as referências anuláveis ligadas, o compilador sabe que ali p é Pedido?: usar p.Numero direto dá o aviso CS8602: Desreferência de uma referência possivelmente nula.
join … into seus + from p in seus.DefaultIfEmpty()A receita antiga: um GroupJoin, e depois "abrir" cada grupo; o DefaultIfEmpty() ("padrão se vazio") troca um grupo vazio por um grupo com um único null.
clientes.RightJoin(pedidos, …)O espelho: todo pedido aparece, e o cliente pode vir null (o pedido 104). Por isso o c?.Nome ?? "…" (veja ?. e ??).
Código antigo usa a receita

O LeftJoin e o RightJoin só existem do .NET 10 em diante. Num projeto .NET 8, clientes.LeftJoin(…) dá o erro CS1061 (‘List<Cliente>’ não contém uma definição para "LeftJoin"…). Por isso você ainda vai ver muito o join … into + DefaultIfEmpty() em tutoriais e projetos: ele faz a mesma coisa.


4. Chave composta: casar por duas coisas

A Ana tem duas lojas, Centro e Bairro, e o preço do caderno é diferente em cada uma. Para achar o estoque certo, não basta o nome do produto: a chave é produto e loja juntos. Monte as duas chaves como tipos anônimos com os mesmos nomes de campo, na mesma ordem:

List<Preco> precos = [new("Caderno", "Centro", 15.00m), new("Caderno", "Bairro", 14.00m), new("Caneta", "Centro", 2.50m)];
List<Estoque> estoques = [new("Caderno", "Bairro", 7), new("Caneta", "Centro", 40)];

var juntos =
    from p in precos
    join e in estoques on new { p.Produto, p.Loja } equals new { e.Produto, e.Loja }
    select $"{p.Produto} na loja {p.Loja}: {e.Quantidade} un. a {p.Valor}";
foreach (var l in juntos) Console.WriteLine(l);

record Preco(string Produto, string Loja, decimal Valor);
record Estoque(string Produto, string Loja, int Quantidade);
$ dotnet run chave.cs
Caderno na loja Bairro: 7 un. a 14,00
Caneta na loja Centro: 40 un. a 2,50

O caderno do Centro não tem estoque cadastrado, então sumiu (é um join interno). Dois tipos anônimos são iguais quando todos os campos são iguais; é isso que faz a chave composta funcionar. Na forma de método, o mesmo seria p => new { p.Produto, p.Loja } e e => new { e.Produto, e.Loja }. Uma tupla também serve: p => (p.Produto, p.Loja).


5. Zip: juntar pela posição

Às vezes as duas listas não têm chave nenhuma: elas simplesmente andam lado a lado. O 1º aluno tirou a 1ª nota, o 2º tirou a 2ª, e assim por diante. Para isso existe o Zip, nome que vem do zíper: os dentes dos dois lados se encaixam um a um.

string[] alunos = ["Ana", "Bruno", "Carla"];
double[] notas = [9.5, 7.0, 8.0];

foreach (var par in alunos.Zip(notas))
{
    Console.WriteLine(par);
}

foreach (var (aluno, nota) in alunos.Zip(notas))
{
    Console.WriteLine($"{aluno} tirou {nota}");
}

var frases = alunos.Zip(notas, (a, n) => $"{a}: {n:N1}");
Console.WriteLine(string.Join(" | ", frases));

string[] turmas = ["A", "B", "A"];
foreach (var (a, n, t) in alunos.Zip(notas, turmas))
{
    Console.WriteLine($"{a} ({t}) = {n}");
}

int[] curtos = [1, 2];
Console.WriteLine(alunos.Zip(curtos).Count());
$ dotnet run zip.cs
(Ana, 9,5)
(Bruno, 7)
(Carla, 8)
Ana tirou 9,5
Bruno tirou 7
Carla tirou 8
Ana: 9,5 | Bruno: 7,0 | Carla: 8,0
Ana (A) = 9,5
Bruno (B) = 7
Carla (A) = 8
2
alunos.Zip(notas)
Devolve tuplas (First, Second): (Ana, 9,5), (Bruno, 7)… First ("primeiro") vem de alunos, Second ("segundo") vem de notas.
var (aluno, nota)
Desconstrói cada tupla em duas variáveis com nomes melhores que First e Second.
Zip(notas, (a, n) => …)
Com um segundo argumento, o Zip monta o resultado que você quiser em vez da tupla.
Zip(notas, turmas)
Também junta três listas de uma vez, em tuplas de três.
alunos.Zip(curtos).Count()
Deu 2: o Zip para quando a lista mais curta acaba. A Carla ficou sem par e foi descartada em silêncio.
O Zip não avisa quando sobra gente

Se você esqueceu uma nota, o último aluno simplesmente some do resultado, sem erro e sem aviso. Quando as listas precisam ter o mesmo tamanho, confira antes: if (alunos.Length != notas.Length) ….

Missão: use Zip para juntar nomes e idades e mostrar uma linha por pessoa: Ana tem 29 anos.

string[] nomes = ["Ana", "Bruno", "Carla"];
int[] idades = [29, 34, 21];

// Use Zip para juntar nomes e idades
Console.WriteLine(nomes.Length);
string[] nomes = ["Ana", "Bruno", "Carla"];
int[] idades = [29, 34, 21];

foreach (var (nome, idade) in nomes.Zip(idades))
{
    Console.WriteLine($"{nome} tem {idade} anos");
}

Qual usar?

Você quer…UseQuem fica de fora
Um par por linha, só quem tem par dos dois ladosJoin / join … equalsOs sem par dos dois lados
Cada item da 1ª lista com a coleção dos seus paresGroupJoin / join … intoOs sem par da 2ª lista
Um par por linha, mantendo todos da 1ª listaLeftJoin .NET 10 (antes: into + DefaultIfEmpty)Os sem par da 2ª lista
Um par por linha, mantendo todos da 2ª listaRightJoin .NET 10Os sem par da 1ª lista
Casar por duas informaçõesChave composta: new { a, b } equals new { a, b }Depende do tipo de junção
Casar pela posição (1º com 1º)ZipO que sobra da lista mais longa
Buscar um item pela chave, muitas vezesUm Dictionary (ToDictionary(c => c.Id))—
E se eu usar dois foreach e um if?

Funciona, mas compara cada cliente com todos os pedidos: com 1.000 clientes e 1.000 pedidos, são um milhão de comparações. O Join monta uma tabela de busca com as chaves da lista de dentro e procura nela, o que é muito mais rápido em listas grandes, além de ficar mais curto de ler.


Quando dá errado

Os lados do equals trocados

Este código tem um erro de propósito.
List<Cliente> clientes = [new(1, "Ana"), new(2, "Bruno"), new(3, "Carla")];
List<Pedido> pedidos = [new(101, 1, 45.90m), new(102, 2, 12.50m), new(103, 1, 30.00m), new(104, 9, 99.00m)];

var juntos =
    from c in clientes
    join p in pedidos on p.ClienteId equals c.Id
    select c.Nome;
Console.WriteLine(juntos.Count());

record Cliente(int Id, string Nome);
record Pedido(int Numero, int ClienteId, decimal Valor);
$ dotnet run troca.cs
/home/ana/cs/troca.cs(6,26): error CS1937: O nome "p" não está no escopo à esquerda de "equals". Considere trocar as expressões em cada lado de "equals".
/home/ana/cs/troca.cs(6,45): error CS1938: O nome "c" não está no escopo à direita de "equals". Considere trocar as expressões em cada lado de "equals".

Ocorreu uma falha no build. Corrija os erros de build e execute novamente.

O que quer dizer: no join, o lado esquerdo do equals só enxerga a variável do from (c), e o direito só enxerga a do join (p). Em inglês: The name 'p' is not in scope on the left side of 'equals'. Como consertar: faça o que a própria mensagem sugere e troque os lados: on c.Id equals p.ClienteId.

Usar == no lugar de equals

Este código tem um erro de propósito.
List<Cliente> clientes = [new(1, "Ana"), new(2, "Bruno"), new(3, "Carla")];
List<Pedido> pedidos = [new(101, 1, 45.90m), new(102, 2, 12.50m), new(103, 1, 30.00m), new(104, 9, 99.00m)];

var juntos =
    from c in clientes
    join p in pedidos on c.Id == p.ClienteId
    select c.Nome;
Console.WriteLine(juntos.Count());

record Cliente(int Id, string Nome);
record Pedido(int Numero, int ClienteId, decimal Valor);
$ dotnet run igual.cs
/home/ana/cs/igual.cs(6,45): error CS0744: Palavra-chave contextual esperada 'equals'
/home/ana/cs/igual.cs(6,45): error CS1525: Termo de expressão inválido "select"

Ocorreu uma falha no build. Corrija os erros de build e execute novamente.

O que quer dizer: "esperava a palavra equals". O compilador leu c.Id == p.ClienteId como uma expressão só, ficou esperando o equals e tropeçou no select (o segundo erro é consequência do primeiro). Como consertar: on c.Id equals p.ClienteId. O join só sabe comparar por igualdade; para outras condições (maior que, contém…), use from duas vezes e um where.

Chaves de tipos diferentes

Este código tem um erro de propósito.
List<Cliente> clientes = [new(1, "Ana"), new(2, "Bruno")];
List<Pedido> pedidos = [new(101, "1", 45.90m)];

var juntos = clientes.Join(pedidos, c => c.Id, p => p.ClienteId, (c, p) => c.Nome);
Console.WriteLine(juntos.Count());

record Cliente(int Id, string Nome);
record Pedido(int Numero, string ClienteId, decimal Valor);
$ dotnet run tipos.cs
/home/ana/cs/tipos.cs(4,23): error CS0411: Os argumentos de tipo do método "Enumerable.Join<TOuter, TInner, TKey, TResult>(IEnumerable<TOuter>, IEnumerable<TInner>, Func<TOuter, TKey>, Func<TInner, TKey>, Func<TOuter, TInner, TResult>)" não podem ser inferidos com base no uso. Tente especificar explicitamente os argumentos de tipo.

Ocorreu uma falha no build. Corrija os erros de build e execute novamente.

O que quer dizer: a mensagem assusta, mas o ponto é o TKey, que aparece duas vezes: as duas chaves precisam ser do mesmo tipo. Aqui uma é int (c.Id) e a outra é string (p.ClienteId), e o compilador não consegue escolher um TKey único. Como consertar: deixe as chaves do mesmo tipo, de preferência nos próprios dados (int ClienteId). Se não der, converta num dos lados: c => c.Id.ToString().

Sumiu gente do resultado (sem erro nenhum)

O programa compila e roda, mas a Carla não aparece, ou o último aluno do Zip some. O que quer dizer: não é bug do LINQ: o Join é interno (só quem tem par) e o Zip para na lista mais curta. Como consertar: para manter todos da primeira lista, use GroupJoin ou LeftJoin; no Zip, confira os tamanhos antes.


Referência

FormaExemploResultado
Joina.Join(b, x => x.Id, y => y.AId, (x, y) => …)Um item por par que casou
join (consulta)from x in a join y in b on x.Id equals y.AId select …Igual ao Join
GroupJoina.GroupJoin(b, x => x.Id, y => y.AId, (x, ys) => …)Um item por elemento de a, com a coleção ys
join … intojoin y in b on x.Id equals y.AId into ysIgual ao GroupJoin
LeftJoin .NET 10a.LeftJoin(b, …, (x, y) => …)Todos de a; y pode ser null
RightJoin .NET 10a.RightJoin(b, …, (x, y) => …)Todos de b; x pode ser null
Left join antigo… into ys from y in ys.DefaultIfEmpty()Igual ao LeftJoin
Chave compostaon new { x.A, x.B } equals new { y.A, y.B }Casa só se as duas partes forem iguais
Zipa.Zip(b), a.Zip(b, (x, y) => …), a.Zip(b, c)Pares (ou trios) pela posição; para na mais curta

Exercícios

1. Complete a junção na sintaxe de consulta:

from c in clientes join p in pedidos on c.Id p.ClienteId select c.Nome

2. Que palavra transforma o join num GroupJoin?

join p in pedidos on c.Id equals p.ClienteId seus

3. Quantos itens tem new[] { 1, 2, 3, 4 }.Zip(new[] { "a", "b" })?

Resposta:

4. Com clientes.Join(pedidos, …), o que acontece com um cliente que não fez pedido?

5. Você quer uma linha por pedido, inclusive pedidos de clientes que não existem, com "cliente desconhecido". Partindo de clientes, qual método do .NET 10 serve?

6. Por que join p in pedidos on p.ClienteId equals c.Id não compila?

Missão final: mostre cada turma com a quantidade e os nomes dos seus alunos, no formato Turma A (Manhã): 3 aluno(s) [Ana, Carla, Davi]. A turma C não tem ninguém, mas precisa aparecer: use GroupJoin (ou join … into).

List<Turma> turmas = [new("A", "Manhã"), new("B", "Tarde"), new("C", "Noite")];
List<Aluno> alunos = [new("Ana", "A"), new("Bruno", "B"), new("Carla", "A"), new("Davi", "A")];

// Use GroupJoin (ou join ... into) para mostrar cada turma com os seus alunos.
// Turmas sem ninguém também precisam aparecer.

record Turma(string Codigo, string Turno);
record Aluno(string Nome, string Turma);
List<Turma> turmas = [new("A", "Manhã"), new("B", "Tarde"), new("C", "Noite")];
List<Aluno> alunos = [new("Ana", "A"), new("Bruno", "B"), new("Carla", "A"), new("Davi", "A")];

var resultado = turmas.GroupJoin(
    alunos,
    t => t.Codigo,
    a => a.Turma,
    (t, seus) => $"Turma {t.Codigo} ({t.Turno}): {seus.Count()} aluno(s) [{string.Join(", ", seus.Select(a => a.Nome))}]");

foreach (var linha in resultado)
{
    Console.WriteLine(linha);
}

record Turma(string Codigo, string Turno);
record Aluno(string Nome, string Turma);

A solução, rodando na máquina da Ana:

$ dotnet run turmas.cs
Turma A (Manhã): 3 aluno(s) [Ana, Carla, Davi]
Turma B (Tarde): 1 aluno(s) [Bruno]
Turma C (Noite): 0 aluno(s) []

Resumo

  • Juntar é casar os itens de duas listas por uma chave que aparece nas duas (o Id do cliente e o ClienteId do pedido). As chaves precisam ser do mesmo tipo (senão, CS0411).
  • Join (ou join … in … on … equals …) é a junção interna: só entra quem tem par dos dois lados. No equals, a lista de fora fica à esquerda (senão, CS1937/CS1938), e == não vale (CS0744).
  • GroupJoin (ou join … into) dá cada item da primeira lista com a coleção dos seus pares, mesmo vazia.
  • LeftJoin e RightJoin .NET 10 mantêm todos de um lado, com null no outro; antes, a receita era into + DefaultIfEmpty().
  • Chave composta: new { x.A, x.B } equals new { y.A, y.B }.
  • Zip junta pela posição, devolve tuplas (First, Second) e para quando a lista mais curta acaba, sem avisar.

Próxima aula: Pattern Matching, o jeito moderno de perguntar "que forma tem este objeto?".