Unindo coleções no MongoDB com .NET Core e um pipeline de agregação
Avalie esse Tutorial
Se você está acompanhando minha série .NET Core no MongoDB, deve se lembrar que exploramos a criação de um aplicativo de console simples, bem como de uma API RESTful com suporte básico a CRUD. Em ambos os exemplos, usamos filtros básicos ao interagir com o MongoDB a partir de nossos aplicativos.
Mas e se precisarmos fazer algo um pouco mais complexo, como unir dados de duas coleções diferentes do MongoDB?
Neste tutorial, vamos dar uma olhada nos pipelines de agregação e em algumas maneiras de trabalhar com eles em um aplicativo .NET Core.
Antes de começarmos, existem alguns requisitos que devem ser atendidos para ser bem-sucedido:
- Tenha um cluster do Atlas MongoDB implantado e configurado.
- Instale o .NET Core 6+.
- Instale os conjuntos de dados de amostra do MongoDB.
Usaremos o .NET Core 6.0 para este tutorial específico. Versões mais antigas ou mais recentes podem funcionar, mas há uma chance de que alguns dos comandos sejam um pouco diferentes. A expectativa é que você já tenha um cluster do Atlas pronto para ser usado. Esse pode ser um cluster M0 gratuito ou melhor, mas você precisará configurá-lo adequadamente com funções de usuário e regras de acesso à rede. Você também precisará anexar os conjuntos de dados de amostra do MongoDB.
Como esperamos realizar algumas coisas bastante complicadas neste tutorial, é provavelmente uma boa ideia dividir os dados que entram nele e os dados que esperamos que saiam dele.
Neste tutorial, usaremos o banco de dados sample_mflix e a coleção filmes. Também usaremos uma coleção de playlists personalizadas que adicionaremos ao banco de dados sample_mflix.
Para que você tenha uma ideia dos dados com os quais trabalharemos, pegue o seguinte documento da coleção de filmes:
1 { 2 "_id": ObjectId("573a1390f29313caabcd4135"), 3 "title": "Blacksmith Scene", 4 "plot": "Three men hammer on an anvil and pass a bottle of beer around.", 5 "year": 1893, 6 // ... 7 }
Tudo bem, não incluí o documento inteiro porque ele é muito grande. Conhecer todos os campos não vai ajudar ou prejudicar o exemplo, desde que estejamos familiarizados com o campo
_id
.Agora, vejamos um documento na coleção playlist proposta:
1 { 2 "_id": ObjectId("61d8bb5e2d5fe0c2b8a1007d"), 3 "username": "nraboy", 4 "items": [ 5 "573a1390f29313caabcd42e8", 6 "573a1391f29313caabcd8a82" 7 ] 8 }
Conhecer os campos no documento acima é importante, pois eles serão usados em todos os nossos pipelines de agregação.
Um dos aspectos mais importantes a serem observados entre as duas coleções é o fato de que os campos
_id
são ObjectId
e os valores no campo items
são strings. Falaremos mais sobre isso à medida que avançarmos.Agora que conhecemos nossos documentos de entrada, vamos dar uma olhada no que esperamos como resultado de nossas queries. Se eu fosse fazer query de uma playlist, não ia querer os valores de id para cada um dos filmes. Quero que eles sejam totalmente expandidos, como os seguintes:
1 { 2 "_id": ObjectId("61d8bb5e2d5fe0c2b8a1007d"), 3 "username": "nraboy", 4 "items": [ 5 { 6 "_id": ObjectId("573a1390f29313caabcd4135"), 7 "title": "Blacksmith Scene", 8 "plot": "Three men hammer on an anvil and pass a bottle of beer around.", 9 "year": 1893, 10 // ... 11 }, 12 { 13 "_id": ObjectId("573a1391f29313caabcd8a82"), 14 "title": "The Terminator", 15 "plot": "A movie about some killer robots.", 16 "year": 1984, 17 // ... 18 } 19 ] 20 }
É aqui que os pipelines de agregação entram e alguns se juntam, porque não podemos simplesmente fazer um filtro normal em uma operação
Find
, a menos que queiramos realizar várias operaçõesFind
.Para manter as coisas simples, vamos criar um aplicativo de console que usa nosso pipeline de agregação. Você pode usar a lógica e aplicá-la a um aplicativo web, se for esse o seu interesse.
Na CLI, execute o seguinte:
1 dotnet new console -o MongoExample 2 cd MongoExample 3 dotnet add package MongoDB.Driver
Os comandos acima criarão um novo projeto .NET Core e instalarão o driver MongoDB mais recente para C#. Tudo o que fizermos a seguir acontecerá no arquivo "Program.cs" do projeto
Abra o arquivo "Program.cs" e adicione o seguinte código C#:
1 using MongoDB.Driver; 2 using MongoDB.Bson; 3 4 MongoClient client = new MongoClient("ATLAS_URI_HERE"); 5 6 IMongoCollection<BsonDocument> playlistCollection = client.GetDatabase("sample_mflix").GetCollection<BsonDocument>("playlist"); 7 8 List<BsonDocument> results = playlistCollection.Find(new BsonDocument()).ToList(); 9 10 foreach(BsonDocument result in results) { 11 Console.WriteLine(result["username"] + ": " + string.Join(", ", result["items"])); 12 }
O código acima se conectará a um MongoDB cluster, obterá uma referência à nossa coleção de playlists e despejará todos os documentos dessa coleção no console. Encontrar e retornar todos os documentos da coleção não é um requisito para o pipeline de agregação, mas pode ajudar no processo de aprendizado.
A string
ATLAS_URI_HERE
pode ser obtida no MongoDB Atlas Dashboard após clicar em "Conectar" para um cluster específico.Vamos explorar algumas opções diferentes para criar uma query de pipeline de agregação com o .NET Core. A primeira usará dados brutos do tipo
BsonDocument
.Conhecemos nossos dados de entrada e sabemos nosso resultado esperado, então precisamos criar algumas etapas de pipeline para reuni-los.
Vamos começar com o primeiro estágio:
1 BsonDocument pipelineStage1 = new BsonDocument{ 2 { 3 "$match", new BsonDocument{ 4 { "username", "nraboy" } 5 } 6 } 7 };
O primeiro estágio desse pipeline usa o operador
$match
para encontrar apenas documentos em que o username
é "nraboy". Pode ser mais de um porque não estamos tratando username
como um campo único.Com o filtro em vigor, vamos para o próximo estágio:
1 BsonDocument pipelineStage2 = new BsonDocument{ 2 { 3 "$project", new BsonDocument{ 4 { "_id", 1 }, 5 { "username", 1 }, 6 { 7 "items", new BsonDocument{ 8 { 9 "$map", new BsonDocument{ 10 { "input", "$items" }, 11 { "as", "item" }, 12 { 13 "in", new BsonDocument{ 14 { 15 "$convert", new BsonDocument{ 16 { "input", "$$item" }, 17 { "to", "objectId" } 18 } 19 } 20 } 21 } 22 } 23 } 24 } 25 } 26 } 27 } 28 };
Lembra como os campos do documento
_id
eram ObjectId e o array items
eram strings? Para que a junção seja bem-sucedida, eles precisam ser do mesmo tipo. O segundo estágio do pipeline é mais um estágio de manipulação com o operador $project
. Estamos definindo os campos que queremos passar para o próximo estágio, mas também estamos modificando alguns dos campos, em particular o campo items
. Usando o operador $map
podemos pegar os valores da string e convertê-los em valores ObjectId.Se sua array
items
contivesse ObjectId em vez de valores de string, esse estágio específico não seria necessário. Também pode não ser necessário se você estiver usando classes POCO em vez de tipos BsonDocument
. Essa é uma lição para outro dia.Com os valores dos itens mapeados corretamente, podemos enviá-los para o próximo estágio do pipeline:
1 BsonDocument pipelineStage3 = new BsonDocument{ 2 { 3 "$lookup", new BsonDocument{ 4 { "from", "movies" }, 5 { "localField", "items" }, 6 { "foreignField", "_id" }, 7 { "as", "movies" } 8 } 9 } 10 };
O estágio do pipeline acima é onde a operação JOIN realmente acontece. Estamos analisando a coleção de filmes e estamos usando os campos ObjectId de nossa coleção de playlists para uni-los ao campo
_id
de nossa coleção de filmes. A saída deste JOIN será armazenada em um novo campo movies
.O
$lookup
é como dizer o seguinte:1 SELECT movies 2 FROM playlist 3 JOIN movies ON playlist.items = movies._id
É claro que há mais do que a declaração SQL acima porque
items
é uma array, algo com o qual você não pode trabalhar nativamente na maioria dos bancos de dados SQL.Então, a partir de agora, temos nossos dados agregados. No entanto, não é tão elegante quanto o que queríamos em nosso resultado final. Isso ocorre porque a saída
$lookup
é um array que nos deixará com uma matriz multidimensional. Lembre-se, items
era um array e cada movies
é um array. Não é a coisa mais agradável de se trabalhar, então provavelmente vamos manipular ainda mais os dados em outro estágio.1 BsonDocument pipelineStage4 = new BsonDocument{ 2 { "$unwind", "$movies" } 3 };
O estágio acima pegará nosso novo campo
movies
e o nivelará com o operador $unwind
. O operador $unwind
basicamente pega cada elemento de uma array e cria um novo item de resultado para ficar adjacente ao restante dos campos do documento pai. Se você tiver, por exemplo, um documento que possui um array com dois elementos, depois de fazer um $unwind
, terá dois documentos.Nosso objetivo final, no entanto, é terminar com um array de filmes de dimensão única, para que possamos corrigir isso com outro estágio do pipeline.
1 BsonDocument pipelineStage5 = new BsonDocument{ 2 { 3 "$group", new BsonDocument{ 4 { "_id", "$_id" }, 5 { 6 "username", new BsonDocument{ 7 { "$first", "$username" } 8 } 9 }, 10 { 11 "movies", new BsonDocument{ 12 { "$addToSet", "$movies" } 13 } 14 } 15 } 16 } 17 };
O estágio acima agrupará nossos documentos e adicionará nossos filmes não processados a um novo campo
movies
, que não é multidimensional.Então, vamos reunir os estágios do pipeline para que possam ser executados em nosso aplicativo.
1 BsonDocument[] pipeline = new BsonDocument[] { 2 pipelineStage1, 3 pipelineStage2, 4 pipelineStage3, 5 pipelineStage4, 6 pipelineStage5 7 }; 8 9 List<BsonDocument> pResults = playlistCollection.Aggregate<BsonDocument>(pipeline).ToList(); 10 11 foreach(BsonDocument pResult in pResults) { 12 Console.WriteLine(pResult); 13 }
Executar o código até agora deve nos dar o resultado esperado em termos de dados e formato.
Agora, você pode estar pensando que o pipeline de cinco estágios acima foi muito trabalhoso para uma operação JOIN. Há alguns aspectos dos quais você deve estar ciente:
- Nossos valores de id não eram do mesmo tipo, o que resultou em outro estágio.
- Nossos valores a serem unidos estavam em uma array, e não em um relacionamento de um para um.
O que estou tentando dizer é que o tamanho e a complexidade do seu pipeline dependerão de como você escolheu modelar seus dados.
Vamos dar uma olhada em outra maneira de alcançar o resultado desejado. Podemos usar a API Fluent que o MongoDB oferece em vez de criar um array de estágios de pipeline.
Dê uma olhada no seguinte:
1 var pResults = playlistCollection.Aggregate() 2 .Match(new BsonDocument{{ "username", "nraboy" }}) 3 .Project(new BsonDocument{ 4 { "_id", 1 }, 5 { "username", 1 }, 6 { 7 "items", new BsonDocument{ 8 { 9 "$map", new BsonDocument{ 10 { "input", "$items" }, 11 { "as", "item" }, 12 { 13 "in", new BsonDocument{ 14 { 15 "$convert", new BsonDocument{ 16 { "input", "$$item" }, 17 { "to", "objectId" } 18 } 19 } 20 } 21 } 22 } 23 } 24 } 25 } 26 }) 27 .Lookup("movies", "items", "_id", "movies") 28 .Unwind("movies") 29 .Group(new BsonDocument{ 30 { "_id", "$_id" }, 31 { 32 "username", new BsonDocument{ 33 { "$first", "$username" } 34 } 35 }, 36 { 37 "movies", new BsonDocument{ 38 { "$addToSet", "$movies" } 39 } 40 } 41 }) 42 .ToList(); 43 44 foreach(var pResult in pResults) { 45 Console.WriteLine(pResult); 46 }
No exemplo acima, usamos métodos como
Match
, Project
, Lookup
, Unwind
e Group
para obter o resultado final. Para alguns desses métodos, não foi necessário usar BsonDocument
como vimos no exemplo anterior.Você acabou de ver duas maneiras de fazer um pipeline de agregação do MongoDB para unir coleções em um aplicativo .NET Core. Como mencionado anteriormente, há algumas maneiras de realizar o que queremos, e todas elas dependerão de como você escolheu modelar os dados em suas coleções.
Há uma terceira maneira, que exploraremos em outro tutorial, e ela usa o LINQ para realizar o trabalho.
Se tiver dúvidas sobre algo que viu neste tutorial, visite os Fóruns MongoDB Community e deixe-se envolver!