Validação de documentos para coleções polimórficas
Avalie esse Artigo
Em Design Reviews de modelagem de dados com clientes, geralmente proponho um esquema em que documentos diferentes na mesma collection contêm diferentes tipos de dados. Isso torna eficiente a busca de documentos relacionados em uma única consulta indexada. O esquema flexível do MongoDB é ótimo para otimizar cargas de trabalho dessa forma, mas as pessoas podem se preocupar em perder o controle sobre quais aplicativos gravam nessascollections.
Os clientes geralmente se preocupam em garantir que apenas documentos formatados corretamente sejam incluídos em uma collection, por isso explico o recurso de validação de esquema do MongoDB. Surge então a pergunta: "Como isso funciona com um esquema polimórfico/de collection única?" Esta postagem pretende responder a essa pergunta – e é mais simples do que você imagina.
O aplicativo em que estou trabalhando gerencia os detalhes do cliente e da conta. Há uma relação de muitos para muitos entre clientes e contas. O aplicativo precisa ser capaz de consultar com eficiência os dados do cliente com base no ID do cliente e os dados da conta com base no ID do cliente ou no ID da conta.
Aqui está um exemplo de documentos de clientes e contas em que minha esposa e eu compartilhamos uma conta corrente, mas cada um tem sua própria conta poupança:
1 { 2 "_id": "kjfgjebgjfbkjb", 3 "customerId": "CUST-123456789", 4 "docType": "customer", 5 "name": { 6 "title": "Mr", 7 "first": "Andrew", 8 "middle": "James", 9 "last": "Morgan" 10 }, 11 "address": { 12 "street1": "240 Blackfriars Rd", 13 "city": "London", 14 "postCode": "SE1 8NW", 15 "country": "UK" 16 }, 17 "customerSince": ISODate("2005-05-20") 18 } 19 20 { 21 "_id": "jnafjkkbEFejfleLJ", 22 "customerId": "CUST-987654321", 23 "docType": "customer", 24 "name": { 25 "title": "Mrs", 26 "first": "Anne", 27 "last": "Morgan" 28 }, 29 "address": { 30 "street1": "240 Blackfriars Rd", 31 "city": "London", 32 "postCode": "SE1 8NW", 33 "country": "UK" 34 }, 35 "customerSince": ISODate("2003-12-01") 36 } 37 38 { 39 "_id": "dksfmkpGJPowefjdfhs", 40 "accountNumber": "ACC1000000654", 41 "docType": "account", 42 "accountType": "checking", 43 "customerId": [ 44 "CUST-123456789", 45 "CUST-987654321" 46 ], 47 "dateOpened": ISODate("2003-12-01"), 48 "balance": NumberDecimal("5067.65") 49 } 50 51 { 52 "_id": "kliwiiejeqydioepwj", 53 "accountNumber": "ACC1000000432", 54 "docType": "account", 55 "accountType": "savings", 56 "customerId": [ 57 "CUST-123456789" 58 ], 59 "dateOpened": ISODate("2005-10-28"), 60 "balance": NumberDecimal("10341.21") 61 } 62 63 { 64 "_id": "djahspihhfheiphfipewe", 65 "accountNumber": "ACC1000000890", 66 "docType": "account", 67 "accountType": "savings", 68 "customerId": [ 69 "CUST-987654321" 70 ], 71 "dateOpened": ISODate("2003-12-15"), 72 "balance": NumberDecimal("10341.89") 73 }
Como um aparte, esses são os índices que adicionei para tornar mais eficientes as consultas frequentes a que me referi:
1 const indexKeys1 = { accountNumber: 1 }; 2 const indexKeys2 = { customerId: 1, accountType: 1 }; 3 const indexOptions1 = { partialFilterExpression: { docType: 'account' }}; 4 const indexOptions2 = { partialFilterExpression: { docType: 'customer' }}; 5 6 db.getCollection(collection).createIndex(indexKeys1, indexOptions1); 7 db.getCollection(collection).createIndex(indexKeys2, indexOptions2);
A validação de esquema permite criar regras de validação para seus campos, como tipos de dados permitidos e intervalos de valores.
O MongoDB usa um modelo de esquema flexível, o que significa que os documentos em uma coleção não precisam ter os mesmos campos ou tipos de dados por padrão. Depois de estabelecer um esquema de aplicativo, você pode usar a validação de esquema para garantir que não haja alterações de esquema não intencionais ou tipos de dados impróprios.
As regras de validação são bem simples de configurar, e ferramentas como o Hackolade podem torná-las ainda mais simples – até mesmo a engenharia reversa de seus documentos existentes.
É simples imaginar a configuração de uma regra de validação do esquema JSON para uma collection em que todos os documentos compartilham os mesmos atributos e tipos. Mas e quanto às coleções polimórficas? Mesmo em collection polimórficas, há estrutura nos documentos. Felizmente, a sintaxe para configurar as regras de validação permite a opcionalidade necessária.
Tenho dois tipos diferentes de documentos que gostaria de armazenar em minha collection
Accounts
— customer
e account
. Incluí um atributodocType
em cada documento para identificar que tipo de entidade ele representa.Começando criando uma definição de JSON schema para cada tipo de documento:
1 const customerSchema = { 2 required: ["docType", "customerId", "name", "customerSince"], 3 properties: { 4 docType: { enum: ["customer"] }, 5 customerId: { bsonType: "string"}, 6 name: { 7 bsonType: "object", 8 required: ["first", "last"], 9 properties: { 10 title: { enum: ["Mr", "Mrs", "Ms", "Dr"]}, 11 first: { bsonType: "string" }, 12 middle: { bsonType: "string" }, 13 last: { bsonType: "string" } 14 } 15 }, 16 address: { 17 bsonType: "object", 18 required: ["street1", "city", "postCode", "country"], 19 properties: { 20 street1: { bsonType: "string" }, 21 street2: { bsonType: "string" }, 22 postCode: { bsonType: "string" }, 23 country: { bsonType: "string" } 24 } 25 }, 26 customerSince: { 27 bsonType: "date" 28 } 29 } 30 }; 31 32 const accountSchema = { 33 required: ["docType", "accountNumber", "accountType", "customerId", "dateOpened", "balance"], 34 properties: { 35 docType: { enum: ["account"] }, 36 accountNumber: { bsonType: "string" }, 37 accountType: { enum: ["checking", "savings", "mortgage", "loan"] }, 38 customerId: { bsonType: "array" }, 39 dateOpened: { bsonType: "date" }, 40 balance: { bsonType: "decimal" } 41 } 42 };
Essas definições definem quais atributos devem estar no documento e quais tipos eles devem receber. Observe que os campos podem ser opcionais — como
name.middle
no esquemacustomer
.Em seguida, é uma simples questão de usar o operador de JSON schema
oneOf
para permitir documentos que correspondam a qualquer um dos dois esquemas:1 const schemaValidation = { 2 $jsonSchema: { oneOf: [ customerSchema, accountSchema ] } 3 }; 4 5 db.createCollection(collection, {validator: schemaValidation});
Eu queria Go um estágio além e adicionar mais algumas validações semânticas:
- Para documentos
customer
, o valorcustomerSince
não pode ser anterior à hora atual. - Para documentos
account
, o valordateOpened
não pode ser anterior à hora atual. - Para contas de poupança, o
balance
não pode ficar abaixo de zero.
Esses documentos representam essas verificações:
1 const badCustomer = { 2 "$expr": { "$gt": ["$customerSince", "$$NOW"] } 3 }; 4 5 const badAccount = { 6 $or: [ 7 { 8 accountType: "savings", 9 balance: { $lt: 0} 10 }, 11 { 12 "$expr": { "$gt": ["$dateOpened", "$$NOW"]} 13 } 14 ] 15 }; 16 17 const schemaValidation = { 18 "$and": [ 19 { $jsonSchema: { oneOf: [ customerSchema, accountSchema ] }}, 20 { $nor: [ 21 badCustomer, 22 badAccount 23 ] 24 } 25 ] 26 };
Atualizei as regras de validação da coleção para incluir estas novas verificações:
1 const schemaValidation = { 2 "$and": [ 3 { $jsonSchema: { oneOf: [ customerSchema, accountSchema ] }}, 4 { $nor: [ 5 badCustomer, 6 badAccount 7 ] 8 } 9 ] 10 }; 11 12 db.createCollection(collection, {validator: schemaValidation} );
Se você quiser recriar isso em seu próprio MongoDB database, basta colar isso em seu playground do MongoDB no VS Code:
1 const cust1 = { 2 "_id": "kjfgjebgjfbkjb", 3 "customerId": "CUST-123456789", 4 "docType": "customer", 5 "name": { 6 "title": "Mr", 7 "first": "Andrew", 8 "middle": "James", 9 "last": "Morgan" 10 }, 11 "address": { 12 "street1": "240 Blackfriars Rd", 13 "city": "London", 14 "postCode": "SE1 8NW", 15 "country": "UK" 16 }, 17 "customerSince": ISODate("2005-05-20") 18 } 19 20 const cust2 = { 21 "_id": "jnafjkkbEFejfleLJ", 22 "customerId": "CUST-987654321", 23 "docType": "customer", 24 "name": { 25 "title": "Mrs", 26 "first": "Anne", 27 "last": "Morgan" 28 }, 29 "address": { 30 "street1": "240 Blackfriars Rd", 31 "city": "London", 32 "postCode": "SE1 8NW", 33 "country": "UK" 34 }, 35 "customerSince": ISODate("2003-12-01") 36 } 37 38 const futureCustomer = { 39 "_id": "nansfanjnDjknje", 40 "customerId": "CUST-666666666", 41 "docType": "customer", 42 "name": { 43 "title": "Mr", 44 "first": "Wrong", 45 "last": "Un" 46 }, 47 "address": { 48 "street1": "240 Blackfriars Rd", 49 "city": "London", 50 "postCode": "SE1 8NW", 51 "country": "UK" 52 }, 53 "customerSince": ISODate("2025-05-20") 54 } 55 56 const acc1 = { 57 "_id": "dksfmkpGJPowefjdfhs", 58 "accountNumber": "ACC1000000654", 59 "docType": "account", 60 "accountType": "checking", 61 "customerId": [ 62 "CUST-123456789", 63 "CUST-987654321" 64 ], 65 "dateOpened": ISODate("2003-12-01"), 66 "balance": NumberDecimal("5067.65") 67 } 68 69 const acc2 = { 70 "_id": "kliwiiejeqydioepwj", 71 "accountNumber": "ACC1000000432", 72 "docType": "account", 73 "accountType": "savings", 74 "customerId": [ 75 "CUST-123456789" 76 ], 77 "dateOpened": ISODate("2005-10-28"), 78 "balance": NumberDecimal("10341.21") 79 } 80 81 const acc3 = { 82 "_id": "djahspihhfheiphfipewe", 83 "accountNumber": "ACC1000000890", 84 "docType": "account", 85 "accountType": "savings", 86 "customerId": [ 87 "CUST-987654321" 88 ], 89 "dateOpened": ISODate("2003-12-15"), 90 "balance": NumberDecimal("10341.89") 91 } 92 93 const futureAccount = { 94 "_id": "kljkdfgjkdsgjklgjdfgkl", 95 "accountNumber": "ACC1000000999", 96 "docType": "account", 97 "accountType": "savings", 98 "customerId": [ 99 "CUST-987654333" 100 ], 101 "dateOpened": ISODate("2030-12-15"), 102 "balance": NumberDecimal("10341.89") 103 } 104 105 const negativeSavings = { 106 "_id": "shkjahsjdkhHK", 107 "accountNumber": "ACC1000000666", 108 "docType": "account", 109 "accountType": "savings", 110 "customerId": [ 111 "CUST-9837462376" 112 ], 113 "dateOpened": ISODate("2005-10-28"), 114 "balance": NumberDecimal("-10341.21") 115 } 116 117 const indexKeys1 = { accountNumber: 1 } 118 const indexKeys2 = { customerId: 1, accountType: 1 } 119 const indexOptions1 = { partialFilterExpression: { docType: 'account' }} 120 const indexOptions2 = { partialFilterExpression: { docType: 'customer' }} 121 122 const customerSchema = { 123 required: ["docType", "customerId", "name", "customerSince"], 124 properties: { 125 docType: { enum: ["customer"] }, 126 customerId: { bsonType: "string"}, 127 name: { 128 bsonType: "object", 129 required: ["first", "last"], 130 properties: { 131 title: { enum: ["Mr", "Mrs", "Ms", "Dr"]}, 132 first: { bsonType: "string" }, 133 middle: { bsonType: "string" }, 134 last: { bsonType: "string" } 135 } 136 }, 137 address: { 138 bsonType: "object", 139 required: ["street1", "city", "postCode", "country"], 140 properties: { 141 street1: { bsonType: "string" }, 142 street2: { bsonType: "string" }, 143 postCode: { bsonType: "string" }, 144 country: { bsonType: "string" } 145 } 146 }, 147 customerSince: { 148 bsonType: "date" 149 } 150 } 151 } 152 153 const accountSchema = { 154 required: ["docType", "accountNumber", "accountType", "customerId", "dateOpened", "balance"], 155 properties: { 156 docType: { enum: ["account"] }, 157 accountNumber: { bsonType: "string" }, 158 accountType: { enum: ["checking", "savings", "mortgage", "loan"] }, 159 customerId: { bsonType: "array" }, 160 dateOpened: { bsonType: "date" }, 161 balance: { bsonType: "decimal" } 162 } 163 } 164 165 const badCustomer = { 166 "$expr": { "$gt": ["$customerSince", "$$NOW"] } 167 } 168 169 const badAccount = { 170 $or: [ 171 { 172 accountType: "savings", 173 balance: { $lt: 0} 174 }, 175 { 176 "$expr": { "$gt": ["$dateOpened", "$$NOW"]} 177 } 178 ] 179 } 180 181 const schemaValidation = { 182 "$and": [ 183 { $jsonSchema: { oneOf: [ customerSchema, accountSchema ] }}, 184 { $nor: [ 185 badCustomer, 186 badAccount 187 ] 188 } 189 ] 190 } 191 192 const database = 'MongoBank'; 193 const collection = 'Accounts'; 194 195 use(database); 196 db.getCollection(collection).drop(); 197 db.createCollection(collection, {validator: schemaValidation} ) 198 db.getCollection(collection).replaceOne({"_id": cust1._id}, cust1, {upsert: true}); 199 db.getCollection(collection).replaceOne({"_id": cust2._id}, cust2, {upsert: true}); 200 db.getCollection(collection).replaceOne({"_id": acc1._id}, acc1, {upsert: true}); 201 db.getCollection(collection).replaceOne({"_id": acc2._id}, acc2, {upsert: true}); 202 db.getCollection(collection).replaceOne({"_id": acc3._id}, acc3, {upsert: true}); 203 204 // The following 3 operations should fail 205 206 db.getCollection(collection).replaceOne({"_id": negativeSavings._id}, negativeSavings, {upsert: true}); 207 db.getCollection(collection).replaceOne({"_id": futureCustomer._id}, futureCustomer, {upsert: true}); 208 db.getCollection(collection).replaceOne({"_id": futureAccount._id}, futureAccount, {upsert: true}); 209 210 db.getCollection(collection).dropIndexes(); 211 db.getCollection(collection).createIndex(indexKeys1, indexOptions1); 212 db.getCollection(collection).createIndex(indexKeys2, indexOptions2);
Espero que este breve artigo tenha mostrado como é fácil usar validações de esquema com as coleções polimórficas do MongoDB e o padrão de design de coleção única.
Não Go em muitos detalhes sobre por que escolhai o modelo de dados usado neste exemplo. Se você quiser saber mais (e deveria!), então aqui estão alguns ótimos recursos sobre modelagem de dados com o MongoDB:
- A série de postagens de blog de sobre padrões do esquema do MongoDB
- A igualmente excelente série de postagens de blog de Daniel Coupal e Lauren Schaefer sobre os antipadrões do MongoDB
- Curso da MongoDB UniversityMongoDB