Bases de datos documentales
En resumen: Una base de datos documental como MongoDB guarda cada registro como un documento con campos anidados y arrays, sin un esquema único para todos los registros.
Problema
Productos que no se parecen
En la tabla products cada producto tiene las mismas columnas: nombre, categoría, precio, stock. La tienda quiere mostrar más. El peso y los alérgenos del pan, el origen y el tueste del café, el volumen del detergente. La tabla deja dos malas opciones. La primera son decenas de columnas que son NULL para la mayoría de los productos. La segunda es una tabla aparte de «propiedad, valor», donde todo es texto y cada lectura es un JOIN más.
Un pedido tiene un problema parecido. Para mostrárselo al cliente, lo compones a partir de cuatro tablas:
El sitio siempre muestra el pedido entero: el cliente, las líneas, los precios. Y la base de datos lo vuelve a componer cada vez.
La idea
El documento
Una base de datos documental guarda cada registro como un documento: un objeto con campos, como JSON. Los documentos están en una colección, que se parece a una tabla pero no decide los campos. Cada documento tiene los suyos. A diferencia de una base clave-valor, una base documental ve dentro del valor y busca por sus campos.
El campo es un simulador con los comandos de MongoDB. Su consola se llama mongosh, y los comandos empiezan con db. y el nombre de la colección:
El pan tiene weight_g y allergens, el café origin y roast, el detergente volume_ml. Un valor puede ser un array (allergens) u otro documento. _id es la clave primaria: cada documento tiene una y no se repite.
Más precisamente, para curiosos
Si no das un _id, MongoDB añade por sí mismo un ObjectId('…'): 12 bytes que contienen el momento de creación y una parte aleatoria. El simulador los reparte en orden (ObjectId('6720a0000000000000000001'), luego …0002), para que la salida sea la misma siempre. Los reales no van así. Sin sort() el simulador devuelve los documentos en el orden en que entraron. MongoDB no promete ningún orden sin sort(). La salida es como en mongosh 2: el texto va entre comillas simples, y los documentos largos se parten con un campo por línea.
Pruébalo
Buscar en los documentos
find(filter, projection) busca. El filtro es un documento con los campos que tienen que coincidir. { price: { $lt: 3 } } significa «precio menor que 3»: $lt es «menor que», $lte «menor o igual», $gt y $gte «mayor», $ne «distinto», $in «uno de los enumerados». $exists pregunta si el campo existe. La proyección dice qué campos devolver: 1 significa «sí», y _id: 0 quita _id, que si no viene siempre. .sort({ price: 1 }) ordena de forma ascendente (-1 es descendente), y countDocuments cuenta:
El filtro { allergens: 'leche' } encuentra los documentos cuyo array contiene 'leche'. Ojo: { allergens: ['leche'] } busca justo ese array, con un elemento y nada más.
Muestra los nombres de los productos que contienen gluten, sin _id.
Solución
db.products.find({ allergens: 'gluten' }, { name: 1, _id: 0 })
db.products.find({ allergens: 'gluten' }, { name: 1, _id: 0 }) encuentra el pan y el bollo. Con ['gluten'] el bollo se cae, porque su array es ['gluten', 'leche']. Y con allergen en lugar de allergens no sale nada, y la base de datos no dice por qué.
Míralo
Un error de escritura en el nombre del campo
La libertad tiene un precio. Una tabla conoce los nombres de sus columnas: un UPDATE de una columna prise que no existe da un error. Una colección no conoce nombres. El café se abarata, 12,90 €, pero hay una errata en el nombre del campo:
La base de datos responde modifiedCount: 1 y no da ningún error. Ha añadido un campo nuevo prise, y price sigue siendo 14.9. Por eso el café no sale entre los productos por menos de 13 €. Un error así solo se descubre cuando alguien nota el precio antiguo en el sitio.
Por eso el programa que escribe en una base de datos documental vigila la forma por sí mismo: una clase o un esquema en el código para cada tipo de documento. MongoDB también puede comprobar los documentos al escribir contra reglas de la colección.
Más precisamente, para curiosos
Las reglas se definen con db.createCollection('products', { validator: { $jsonSchema: … } }) o más tarde con el comando collMod. Dicen qué campos son obligatorios y de qué tipo. El simulador no tiene esa comprobación.
La idea
Incrustar o referenciar
El pedido del principio puede ser un solo documento, con el cliente y las líneas dentro. Esto se llama incrustar:
findOne lo devuelve entero en una lectura, sin JOIN. Llegas a un campo del documento incrustado con un punto: 'customer.city'. La ruta va entre comillas porque tiene un punto. Lo mismo funciona a través de un array. En este campo la colección orders ya tiene los pedidos 53–60 de la tienda, y 'items.product_id': 1 encuentra aquellos en los que alguna línea es del pan blanco:
El nombre y el precio de las líneas son copias, y es a propósito: el pedido conserva el precio del día de la compra, como unit_price en order_items. El nombre del cliente también es una copia. Si Maria cambia su nombre, los pedidos antiguos conservan el viejo. Para un pedido eso es lo correcto, pero el perfil del cliente vive en su propia colección, y el pedido solo lleva customer.id.
Incrustas cuando la parte pertenece al documento, se lee con él y es pequeña: un pedido tiene unas pocas líneas, no miles. Usas una referencia cuando algo lo comparten muchos documentos o crece sin fin. Las reseñas del café son así: hay muchas, se escriben en cualquier momento y cada una tiene su propio autor. Están en su propia colección, y product_id apunta al producto, como una clave foránea:
Incrustar es desnormalizar: lecturas más rápidas a cambio de copias que pueden desviarse.
Más precisamente, para curiosos
Un documento en MongoDB pesa como máximo 16 MB, así que un array que crece sin fin tarde o temprano no cabe en el documento. Las referencias se unen en aggregate con la etapa $lookup (con from, localField, foreignField y as), que hace algo parecido a un LEFT JOIN. El simulador solo la conoce de esa forma. Si necesitas $lookup en cada consulta, probablemente tus datos son relacionales.
Pruébalo
Cambios dentro del documento
updateOne(filter, change) cambia el primer documento que encuentra, y updateMany todos. El cambio es un operador: $set pone un valor (y crea el campo si no existe), $inc suma a un número, $push añade a un array, $unset quita un campo. Este campo vuelve a tener los pedidos 53–60. El pedido 60 está entregado, se le añade mantequilla, y el pan blanco pasa a tres:
'items.$.qty' significa «la qty de la línea que encontró el filtro». El filtro 'items.product_id': 1 encuentra la línea del pan blanco, y $ es su lugar en el array. El documento cambia entero de una vez: nadie puede leer el pedido a medio cambiar.
Sin un operador mongosh se niega: updateOne({ _id: 60 }, { status: 'delivered' }) da el error Update document requires atomic operators. Y $set sobre todo el array items lo reemplaza, y las líneas antiguas desaparecen.
Viktoria (pedido 58) quiere un pan integral más (producto 2) y dos yogures: { product_id: 6, name: 'Yogur', qty: 2, price: 1.1 }.
Solución
db.orders.updateOne({ _id: 58, 'items.product_id': 2 }, { $inc: { 'items.$.qty': 1 } })
db.orders.updateOne({ _id: 58 }, { $push: { items: { product_id: 6, name: 'Yogur', qty: 2, price: 1.1 } } })
Dos cambios. Primero $inc sobre 'items.$.qty' con el filtro 'items.product_id': 2, luego $push de la línea nueva. Con 'items.0.qty' sube la primera línea, y esa es el pan blanco, no el integral. Y $set sobre items reemplaza todo el array por una línea, y los panes desaparecen.
La idea
Agregación
Las preguntas de «cuánto» y «cuánto por» (ingresos por ciudad, productos más vendidos) se escriben con aggregate: un array de etapas por las que pasan los documentos una tras otra. Esto es una pipeline:
$matchfiltra, como WHERE;$unwinddivide el pedido en un documento por cada línea deitems;$groupreúne los documentos en grupos por_id, como GROUP BY, con$sum,$avg,$miny$max;$projectconserva o calcula campos, y$sorty$limitordenan y cortan.
Los ingresos por ciudad sin los pedidos cancelados, y después el número de pedidos:
{ $sum: 1 } cuenta los documentos del grupo, como COUNT(*). Sin $unwind los grupos sumarían pedidos, no líneas. $round redondea al céntimo: si no, una suma de números fraccionarios sale como 22.799999999999997 en lugar de 22,80 €, como con cualquier número de coma flotante.
Más precisamente, para curiosos
MongoDB no promete un orden de los grupos después de $group, así que después va un $sort, y con valores iguales también una segunda clave (_id: 1). El simulador da los grupos en el orden en que aparecieron. Suma los números fraccionarios un poco distinto de MongoDB y a veces difiere en la última cifra. Después de $round ambos dan lo mismo.
Ejercicio
Envíos, alérgenos y los más vendidos
Los campos de las tareas tienen los pedidos 53–60 y los productos del principio de la lección.
1. El mensajero viene a por los pedidos pagados.
Cambia el estado de todos los pedidos con status: 'paid' a 'shipped'.
Solución
db.orders.updateMany({ status: 'paid' }, { $set: { status: 'shipped' } })
db.orders.updateMany({ status: 'paid' }, { $set: { status: 'shipped' } }). Los pedidos 55 y 59 están pagados. updateOne cambia solo el primero que encuentra (55), y el 59 se queda en paid.
2. Resulta que el bollo de mantequilla lleva también huevo.
Añade 'huevo' a los alérgenos del bollo de mantequilla (producto 3).
Solución
db.products.updateOne({ _id: 3 }, { $push: { allergens: 'huevo' } })
db.products.updateOne({ _id: 3 }, { $push: { allergens: 'huevo' } }) añade al final del array. $set con ['huevo'] reemplaza el array, y el gluten y la leche desaparecen. Y alergens con una sola «l» crea un campo nuevo, igual que prise.
3. El almacén quiere saber qué se vende más.
¿Qué productos vendieron 5 unidades o más (sin los pedidos cancelados)? Muéstralos del más vendido hacia abajo, y con las mismas unidades por nombre.
Solución
db.orders.aggregate([
{ $match: { status: { $ne: 'cancelled' } } },
{ $unwind: '$items' },
{ $group: { _id: '$items.name', sold: { $sum: '$items.qty' } } },
{ $match: { sold: { $gte: 5 } } },
{ $sort: { sold: -1, _id: 1 } },
])
$match quita los cancelados, $unwind divide las líneas, $group por '$items.name' suma $sum: '$items.qty', después un segundo $match sobre el campo nuevo sold (como HAVING) y $sort: { sold: -1, _id: 1 }. Sin $unwind el valor de '$items.qty' es un array, y $sum en $group se salta los arrays. Todo da 0 y nada pasa $gte: 5.
La idea
Cuándo una base documental y cuándo no
Una base de datos documental es buena cuando un registro se lee y se escribe entero y su forma cambia: un catálogo de productos distintos, perfiles y ajustes, el contenido de un sitio, eventos de una aplicación, un pedido que se muestra de una vez.
Es una mala elección cuando los datos están conectados en todas las direcciones. La tienda pregunta «qué cliente compró qué», «qué productos se compran juntos», «ingresos por categoría y mes». Cada una de estas preguntas pasa por clientes, pedidos y productos a la vez. En SQL son JOIN sobre tablas normalizadas, donde cada hecho está escrito una vez. Aquí son copias que hay que mantener iguales, o un $lookup en cada consulta.
Lo mismo vale para una operación que toca muchos documentos: un pago que baja el stock de cinco productos y guarda el pedido. El cambio de un documento es todo o nada. Varios documentos necesitan una transacción, que es más compleja y lenta que una sola escritura.
Más precisamente, para curiosos
Son bases de datos documentales MongoDB (la más extendida, con mongosh y aggregate como en el simulador), Couchbase, Apache CouchDB, Firestore en Google Cloud y Amazon DocumentDB, que acepta los comandos de MongoDB. MongoDB tiene transacciones sobre muchos documentos desde la versión 4.0. PostgreSQL tiene el tipo jsonb: una columna con un documento en una tabla normal, con índices y operadores para sus campos. Ese suele ser el buen término medio: tablas para los datos conectados y jsonb para las propiedades distintas de los productos.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Claves naturales y desnormalización · ACID: todo o nada · Índices · Clave-valor y la caché
- Continúa en: Cómo elegir una base de datos
- Ejercicios sobre el tema: Tipos de bases de datos