Explore o novo chatbot do Developer Center! O MongoDB AI chatbot pode ser acessado na parte superior da sua navegação para responder a todas as suas perguntas sobre o MongoDB .

Saiba por que o MongoDB foi selecionado como um líder no 2024 Gartner_Magic Quadrupnt()
Desenvolvedor do MongoDB
Central de desenvolvedor do MongoDBchevron-right
Produtoschevron-right
MongoDBchevron-right

Peculiaridades de índices exclusivos e documentos exclusivos em uma array de documentos

Artur Costa7 min read • Published Oct 04, 2023 • Updated Oct 04, 2023
MongoDB
Ícone do FacebookÍcone do Twitterícone do linkedin
Avalie esse Tutorial
star-empty
star-empty
star-empty
star-empty
star-empty
Estamos desenvolvendo um aplicativo para resumir a situação financeira de um usuário. A página principal deste aplicativo mostra a identificação do usuário e os saldos de todas as contas bancárias sincronizadas com nossoaplicativo.
Como vimos em postagens de blog e recomendações de como aproveitar ao máximo o MongoDB, "Os dados que são acessados juntos devem ser armazenados juntos." Pensemos no seguinte documento/estrutura para armazenar os dados utilizados na página principal do aplicativo:
1const user = {
2 _id: 1,
3 name: { first: "john", last: "smith" },
4 accounts: [
5 { balance: 500, bank: "abc", number: "123" },
6 { balance: 2500, bank: "universal bank", number: "9029481" },
7 ],
8};
Com base na funcionalidade do nosso aplicativo, determinamos as seguintes regras:
  • Um usuário pode se registrar no aplicativo e não sincronizar uma conta bancária.
  • Uma conta é identificada por seus camposbank e number.
  • A mesma conta não deve ser registrada para dois usuários diferentes.
  • A mesma conta não deve ser registrada várias vezes para o mesmo usuário.
Para reforçar o que foi apresentado acima, decidimos criar um índice com as seguintes características:
  • Como os campos bank e number não devem se repetir, esse índice deve ser definido como Unique.
  • Como estamos indexando mais de um campo, ele será do tipo Compound.
  • Como estamos indexando documentos dentro de uma matriz, ela também será do tipo Multikey.
Como resultado disso, temos um Compound Multikey Unique Index com as seguintes especificações e opções:
1const specification = { "accounts.bank": 1, "accounts.number": 1 };
2const options = { name: "Unique Account", unique: true };
Para validar se nosso índice funciona como pretendíamos, usaremos os seguintes dados em nossos testes:
1const user1 = { _id: 1, name: { first: "john", last: "smith" } };
2const user2 = { _id: 2, name: { first: "john", last: "appleseed" } };
3const account1 = { balance: 500, bank: "abc", number: "123" };
Primeiro, vamos adicionar os usuários à collection:
1db.users.createIndex(specification, options); // Unique Account
2
3db.users.insertOne(user1); // { acknowledged: true, insertedId: 1)}
4db.users.insertOne(user2); // MongoServerError: E11000 duplicate key error collection: test.users index: Unique Account dup key: { accounts.bank: null, accounts.number: null }
Muito bom. Ainda nem começamos a trabalhar com as contas e já temos um erro. Vamos ver o que está acontecendo.
Analisando a mensagem de erro, ela diz que temos uma chave duplicada para o índice Unique Account com o valor de null para os campos accounts.bank e accounts.number. Isso se deve ao modo como a indexação funciona no MongoDB. Quando inserimos um documento em uma collection indexada e esse documento não tem um ou mais campos especificados no índice, o valor dos campos ausentes será considerado nulle uma entrada será adicionada ao índice .
Usando essa lógica para analisar nosso teste anterior, quando inserimos user1, ele não tinha os campos accounts.bank e accounts.number e gerou uma entrada no índice Unique Account com o valor de null para ambos. Quando tentamos inserir user2 na coleção, tivemos o mesmo comportamento, e outra entrada no índice Unique Account teria sido criada se não tivéssemos especificado esse índice como unique. Mais informações sobre campos ausentes e índices exclusivos podem ser encontradas em nossos Docs.
A solução para esse problema é indexar apenas documentos com os campos accounts.bank e accounts.number. Para isso, podemos especificar uma expressão de filtro parcial em nossas opções de índice. Agora temos um Compound Multikey Unique Partial Index (nome bonito, quem estamos tentando imprimir aqui?), com a seguinte especificação e opções:
1const specification = { "accounts.bank": 1, "accounts.number": 1 };
2const optionsV2 = {
3 name: "Unique Account V2",
4 partialFilterExpression: {
5 "accounts.bank": { $exists: true },
6 "accounts.number": { $exists: true },
7 },
8 unique: true,
9};
Voltar aos nossos testes:
1// Cleaning our environment
2db.users.drop({}); // Delete documents and indexes definitions
3
4/* Tests */
5db.users.createIndex(specification, optionsV2); // Unique Account V2
6db.users.insertOne(user1); // { acknowledged: true, insertedId: 1)}
7db.users.insertOne(user2); // { acknowledged: true, insertedId: 2)}
Nossa nova implementação de índice funcionou e agora podemos inserir esses dois usuários sem contas. Vamos testar a duplicação de contas, começando com a mesma conta para dois usuários diferentes:
1// Cleaning the collection
2db.users.deleteMany({}); // Delete only documents, keep indexes definitions
3db.users.insertMany([user1, user2]);
4
5/* Test */
6db.users.updateOne({ _id: user1._id }, { $push: { accounts: account1 } }); // { ... matchedCount: 1, modifiedCount: 1 ...}
7
8db.users.updateOne({ _id: user2._id }, { $push: { accounts: account1 } }); // MongoServerError: E11000 duplicate key error collection: test.users index: Unique Account V2 dup key: { accounts.bank: "abc", accounts.number: "123" }
Não foi possível inserir a mesma conta em diferentes usuários, como esperávamos. Agora, tentaremos a mesma conta para o mesmo usuário.
1// Cleaning the collection
2db.users.deleteMany({}); // Delete only documents, keep indexes definitions
3db.users.insertMany([user1, user2]);
4
5/* Test */
6db.users.updateOne({ _id: user1._id }, { $push: { accounts: account1 } }); // { ... matchedCount: 1, modifiedCount: 1 ...}
7
8db.users.updateOne({ _id: user1._id }, { $push: { accounts: account1 } }); // { ... matchedCount: 1, modifiedCount: 1 ...}
9
10db.users.findOne({ _id: user1._id }); /*{
11 _id: 1,
12 name: { first: 'john', last: 'smith' },
13 accounts: [
14 { balance: 500, bank: 'abc', number: '123' },
15 { balance: 500, bank: 'abc', number: '123' }
16 ]
17}*/
Quando não esperamos que as coisas funcionem, elas funcionam. Novamente, outro erro foi causado por não saber ou considerar como os índices funcionam no MongoDB. Ao ler sobre restrições exclusivas na documentação do MongoDB, vemos que os índices do MongoDB não duplicam entradas estritamente iguais com os mesmos valores de chave apontando para o mesmo documento. Considerando isso, quando inserimos account1 pela segunda vez em nosso usuário, uma entrada de índice não foi criada. Com isso, não temos valores duplicados nele.
Alguns de nós com mais conhecimento do MongoDB podem pensar que o uso de$addToSet em vez de $push resolveria o problema. Não desta vez, pequeno padavan. A função$addToSet consideraria todos os campos no documento da conta, mas, como especificamos no início de nossa viagem, uma conta deve ser exclusiva e identificável pelos campos bank e number.
Ok, o que podemos fazer agora? Nosso índice tem uma tonelada de opções e nomes compostos, e nosso aplicativo não se comporta como esperávamos.
Uma maneira simples de sair dessa situação é alterar a forma como nossa função de atualização está estruturada, alterando seu parâmetro de filtro para corresponder apenas aos documentos do usuário onde a conta que queremos inserir não está na arrayaccounts .
1// Cleaning the collection
2db.users.deleteMany({}); // Delete only documents, keep indexes definitions
3db.users.insertMany([user1, user2]);
4
5/* Test */
6const bankFilter = {
7 $not: { $elemMatch: { bank: account1.bank, number: account1.number } }
8};
9
10db.users.updateOne(
11 { _id: user1._id, accounts: bankFilter },
12 { $push: { accounts: account1 } }
13); // { ... matchedCount: 1, modifiedCount: 1 ...}
14
15db.users.updateOne(
16 { _id: user1._id, accounts: bankFilter },
17 { $push: { accounts: account1 } }
18); // { ... matchedCount: 0, modifiedCount: 0 ...}
19
20db.users.findOne({ _id: user1._id }); /*{
21 _id: 1,
22 name: { first: 'john', last: 'smith' },
23 accounts: [ { balance: 500, bank: 'abc', number: '123' } ]
24}*/
Problema resolvido. Tentamos inserir a mesma conta para o mesmo usuário e ela não inseriu, mas também não ocorreu erro.
Esse comportamento não atende às nossas expectativas porque não deixa claro para o usuário que essa operação é proibida. Outro ponto de preocupação é que essa solução considera que toda vez que uma nova conta é inserida no banco de dados, ela usará os parâmetros corretos do filtro de atualização.
Trabalhamos em algumas empresas e sabemos que, à medida que as pessoas vêm e Go, algum conhecimento sobre a implementação se perde, os estagiários tentarão reinventar a roda e alguns atalhos desagradáveis serão tomados. Queremos uma solução que dê um erro em qualquer caso e impeça até mesmo o desenvolvedor/administrador mais inescrupuloso que se atreve a alterar os dados diretamente no banco de dados 😱 de produção.
Uma nota rápida antes de Go esse papel de coelho. As práticas recomendadas do MongoDB recomendam implementar a validação de esquema no nível do aplicativo e usar a validação de esquema do MongoDB como um backstop.
Na validação de esquema MongoDB, é possível usar o operador $expr para escrever uma expressão de agregação para validar os dados de um documento quando ele tiver sido inserido ou atualizado. Com isso, podemos escrever uma expressão para verificar se os itens dentro de uma array são únicos.
Após algumas considerações, obtemos a seguinte expressão:
1const accountsSet = {
2 $setIntersection: {
3 $map: {
4 input: "$accounts",
5 in: { bank: "$$this.bank", number: "$$this.number" }
6 },
7 },
8};
9
10
11const uniqueAccounts = {
12 $eq: [{ $size: "$accounts" }, { $size: accountsSet }],
13};
14
15
16const accountsValidator = {
17 $expr: {
18 $cond: {
19 if: { $isArray: "$accounts" },
20 then: uniqueAccounts,
21 else: true,
22 },
23 },
24};
Pode parecer um pouco assustador no início, mas podemos Go por isso.
A primeira operação que temos dentro de $expr é um $cond. Quando a lógica especificada no campo if resultar em true, a lógica dentro do campo then será executada. Quando o resultado for false, a lógica dentro do campoelse será executada.
Usando esse conhecimento para interpretar nosso código, quando a matriz de contas existir no documento, { $isArray: "$accounts" }, executaremos a lógica dentro deuniqueAccounts. Quando a matriz não existir, retornaremostrue, indicando que o documento foi aprovado na validação do esquema.
Dentro da variáveluniqueAccounts, verificamos se o $size de duas coisas é $eq. O primeiro é o tamanho do campo de array $accountse o segundo é o tamanho de accountsSet gerado pela função$setIntersection. Se as duas arrays tiverem o mesmo tamanho, a lógica retornará truee o documento passará na validação. Caso contrário, a lógica retornará false, o documento falhará na validação e a operação apresentará erro.
A função$setIntersenction executará uma operação de conjunto na array passada para ela, removendo entradas duplicadas. A array passada para $setIntersection será gerada por uma função$map, que mapeia cada conta em $accounts para ter somente os campos bank e number.
Vamos ver se isso é bruxaria ou ciência:
1// Cleaning the collection
2db.users.drop({}); // Delete documents and indexes definitions
3db.createCollection("users", { validator: accountsValidator });
4db.users.createIndex(specification, optionsV2);
5db.users.insertMany([user1, user2]);
6
7/* Test */
8db.users.updateOne({ _id: user1._id }, { $push: { accounts: account1 } }); // { ... matchedCount: 1, modifiedCount: 1 ...}
9
10db.users.updateOne(
11 { _id: user1._id },
12 { $push: { accounts: account1 } }
13); /* MongoServerError: Document failed validation
14Additional information: {
15 failingDocumentId: 1,
16 details: {
17 operatorName: '$expr',
18 specifiedAs: {
19 '$expr': {
20 '$cond': {
21 if: { '$and': '$accounts' },
22 then: { '$eq': [ [Object], [Object] ] },
23 else: true
24 }
25 }
26 },
27 reason: 'expression did not match',
28 expressionResult: false
29 }
30}*/
Função cumprida! Agora, nossos dados estão protegidos contra aqueles que se atrevem a fazer alterações diretamente no banco de dados.
Para chegar ao comportamento desejado, revisamos os índices do MongoDB com a opçãounique, como adicionar proteções de segurança à nossa coleção com uma combinação de parâmetros na parte de filtro de uma função de atualização e como usar a validação de esquema do MongoDB para adicionar um camada extra de segurança para nossos dados.

Ícone do FacebookÍcone do Twitterícone do linkedin
Avalie esse Tutorial
star-empty
star-empty
star-empty
star-empty
star-empty
Relacionado
Tutorial

Como importar dados no MongoDB com o mongoimport


Jun 12, 2024 | 15 min read
Tutorial

Zap, tweet e repita! Como usar o Zapier com o MongoDB


Sep 09, 2024 | 7 min read
Tutorial

Criar uma camada de acesso a dados Python


Jan 04, 2024 | 12 min read
Podcast

Expansão do setor de jogos com Gaspard Petit, da Square Enix


Mar 22, 2023 | 29 min