Peculiaridades de índices exclusivos e documentos exclusivos em uma array de documentos
Avalie esse Tutorial
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:
1 const 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 campos
bank
enumber
. - 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 resultado disso, temos um
Compound Multikey Unique Index
com as seguintes especificações e opções:1 const specification = { "accounts.bank": 1, "accounts.number": 1 }; 2 const options = { name: "Unique Account", unique: true };
Para validar se nosso índice funciona como pretendíamos, usaremos os seguintes dados em nossos testes:
1 const user1 = { _id: 1, name: { first: "john", last: "smith" } }; 2 const user2 = { _id: 2, name: { first: "john", last: "appleseed" } }; 3 const account1 = { balance: 500, bank: "abc", number: "123" };
Primeiro, vamos adicionar os usuários à collection:
1 db.users.createIndex(specification, options); // Unique Account 2 3 db.users.insertOne(user1); // { acknowledged: true, insertedId: 1)} 4 db.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 null
e 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:1 const specification = { "accounts.bank": 1, "accounts.number": 1 }; 2 const 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 2 db.users.drop({}); // Delete documents and indexes definitions 3 4 /* Tests */ 5 db.users.createIndex(specification, optionsV2); // Unique Account V2 6 db.users.insertOne(user1); // { acknowledged: true, insertedId: 1)} 7 db.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 2 db.users.deleteMany({}); // Delete only documents, keep indexes definitions 3 db.users.insertMany([user1, user2]); 4 5 /* Test */ 6 db.users.updateOne({ _id: user1._id }, { $push: { accounts: account1 } }); // { ... matchedCount: 1, modifiedCount: 1 ...} 7 8 db.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 2 db.users.deleteMany({}); // Delete only documents, keep indexes definitions 3 db.users.insertMany([user1, user2]); 4 5 /* Test */ 6 db.users.updateOne({ _id: user1._id }, { $push: { accounts: account1 } }); // { ... matchedCount: 1, modifiedCount: 1 ...} 7 8 db.users.updateOne({ _id: user1._id }, { $push: { accounts: account1 } }); // { ... matchedCount: 1, modifiedCount: 1 ...} 9 10 db.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 array
accounts
.1 // Cleaning the collection 2 db.users.deleteMany({}); // Delete only documents, keep indexes definitions 3 db.users.insertMany([user1, user2]); 4 5 /* Test */ 6 const bankFilter = { 7 $not: { $elemMatch: { bank: account1.bank, number: account1.number } } 8 }; 9 10 db.users.updateOne( 11 { _id: user1._id, accounts: bankFilter }, 12 { $push: { accounts: account1 } } 13 ); // { ... matchedCount: 1, modifiedCount: 1 ...} 14 15 db.users.updateOne( 16 { _id: user1._id, accounts: bankFilter }, 17 { $push: { accounts: account1 } } 18 ); // { ... matchedCount: 0, modifiedCount: 0 ...} 19 20 db.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.
Validação de esquema do MongoDB para o win.
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:
1 const accountsSet = { 2 $setIntersection: { 3 $map: { 4 input: "$accounts", 5 in: { bank: "$$this.bank", number: "$$this.number" } 6 }, 7 }, 8 }; 9 10 11 const uniqueAccounts = { 12 $eq: [{ $size: "$accounts" }, { $size: accountsSet }], 13 }; 14 15 16 const 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.
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ável
uniqueAccounts
, verificamos se o $size de duas coisas é $eq. O primeiro é o tamanho do campo de array $accounts
e o segundo é o tamanho de accountsSet
gerado pela função$setIntersection. Se as duas arrays tiverem o mesmo tamanho, a lógica retornará true
e 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 2 db.users.drop({}); // Delete documents and indexes definitions 3 db.createCollection("users", { validator: accountsValidator }); 4 db.users.createIndex(specification, optionsV2); 5 db.users.insertMany([user1, user2]); 6 7 /* Test */ 8 db.users.updateOne({ _id: user1._id }, { $push: { accounts: account1 } }); // { ... matchedCount: 1, modifiedCount: 1 ...} 9 10 db.users.updateOne( 11 { _id: user1._id }, 12 { $push: { accounts: account1 } } 13 ); /* MongoServerError: Document failed validation 14 Additional 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ção
unique
, 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.