Capítulo 20 de 23 12 secciones 11 min

Compartir

Cuando las tablas estorban: bases de documentos

Qué es una base NoSQL de documentos, cómo se ve el mismo pedido guardado de las dos maneras, y qué se gana y qué se pierde.

Una base NoSQL de documentos guarda cada cosa entera en un JSON, en vez de repartida en tablas. Se gana leer un pedido de un tirón sin joins y poder cambiar la forma sobre la marcha. Se pierde que nadie te obliga a que dos documentos tengan la misma forma, y eso lo pagas al consultar. Se puede probar entero en SQLite, que trae funciones JSON.

Llevas quince capítulos con tablas, y las tablas son lo correcto casi siempre. Este capítulo es el "casi" 🌸

No hace falta instalar nada raro: SQLite trae funciones JSON desde hace años, así que la idea entera se puede probar acá y después reconocerla en Mongo.

Un pedido, repartido en cuatro tablas

Así lo diseñamos en el capítulo 10, y así está bien diseñado:

SELECT p.id, c.nombre, pr.nombre AS producto, de.cantidad
FROM pedidos p
JOIN clientes c   ON c.id = p.id_cliente
JOIN detalle de   ON de.id_pedido = p.id
JOIN productos pr ON pr.id = de.id_producto
WHERE p.id = 1;
id  nombre                  producto     cantidad
--  ----------------------  -----------  --------
1     MARKET CENTRAL 069    Producto 33  6
1     MARKET CENTRAL 069    Producto 03  22
1     MARKET CENTRAL 069    Producto 32  9
1     MARKET CENTRAL 069    Producto 34  12
1     MARKET CENTRAL 069    Producto 09  12

Un pedido, tres tablas unidas y cinco filas. El nombre del cliente se repite cinco veces en el resultado, y eso está bien: en disco está una sola vez, que es de lo que iba la normalización.

Ahora imagínate que esto no es un reporte, es la pantalla de una app que tiene que abrir un pedido. Cada vez que alguien toca un pedido, tres joins.

El mismo pedido, en una sola pieza

CREATE TABLE documentos (id INTEGER PRIMARY KEY, cuerpo TEXT);

INSERT INTO documentos (id, cuerpo)
SELECT p.id, json_object('pedido', p.id, 'fecha', p.fecha, 'canal', p.canal,
  'cliente', json_object('nombre', c.nombre, 'ciudad', c.ciudad),
  'lineas', (SELECT json_group_array(json_object('producto', pr.nombre,
                                                 'cantidad', de.cantidad))
             FROM detalle de JOIN productos pr ON pr.id = de.id_producto
             WHERE de.id_pedido = p.id))
FROM pedidos p JOIN clientes c ON c.id = p.id_cliente;

SELECT count(*) AS documentos FROM documentos;
documentos
----------
876

876 pedidos convertidos en 876 documentos. Míralo por dentro, uno cortito:

SELECT cuerpo FROM documentos WHERE id = 5;
cuerpo
--------------------------------------------------------------------------------------------------------------------------------------------------------------
{"pedido":5,"fecha":"2025-07-09","canal":"Web","cliente":{"nombre":"Almacenes Vega 067","ciudad":"Piura"},"lineas":[{"producto":"Producto 09","cantidad":17}]}

Ahí está el pedido entero: la cabecera, el cliente y sus líneas, todo en una fila. Eso es un documento, y un montón de documentos juntos es una colección, que es como Mongo llama a lo que aquí es una tabla.

Fíjate en las lineas: es una lista dentro del documento. En una tabla eso no se puede, y por eso hacía falta la tabla detalle.

Consultarlo

SELECT json_extract(cuerpo,'$.cliente.ciudad') AS ciudad,
       json_extract(cuerpo,'$.canal')          AS canal,
       json_array_length(cuerpo,'$.lineas')    AS lineas
FROM documentos WHERE id = 5;
ciudad  canal  lineas
------  -----  ------
Piura   Web    1

El $.cliente.ciudad se lee como una ruta: entra en cliente y saca ciudad. En Mongo se escribe "cliente.ciudad", que es lo mismo con menos símbolos.

Y agrupar funciona igual que siempre:

SELECT json_extract(cuerpo,'$.cliente.ciudad') AS ciudad, count(*) AS pedidos
FROM documentos GROUP BY 1 ORDER BY 2 DESC LIMIT 5;
ciudad    pedidos
--------  -------
Chiclayo  212
Arequipa  168
Piura     167
Trujillo  113
Cusco     112

Mismo GROUP BY del capítulo 6, solo que la columna se saca de dentro del JSON.

Lo que cuesta: desanidar

Pregunta normal de negocio: qué producto se vende más. Con tablas es un GROUP BY sobre detalle y se acabó. Acá las líneas están metidas dentro de cada documento, así que primero hay que sacarlas:

SELECT json_extract(l.value,'$.producto') AS producto,
       sum(json_extract(l.value,'$.cantidad')) AS unidades
FROM documentos d, json_each(d.cuerpo,'$.lineas') l
GROUP BY 1 ORDER BY 2 DESC LIMIT 5;
producto     unidades
-----------  --------
Producto 22  1046
Producto 38  986
Producto 23  978
Producto 09  961
Producto 13  933

json_each abre la lista y devuelve una fila por elemento. En Mongo eso se llama $unwind y hace exactamente lo mismo.

Funciona, y fíjate en lo que pasó: para contestar una pregunta que cruza pedidos, tuviste que volver a convertir los documentos en filas. Ahí está el resumen del capítulo: el documento es cómodo para leer una cosa entera, e incómodo para preguntar por todas a la vez.

Y ahora la parte que muerde

Meto un documento que no se parece a los otros:

INSERT INTO documentos (id, cuerpo)
VALUES (99999, json_object('pedido', 99999, 'canal', 'WhatsApp',
                           'total_soles', 250, 'nota', 'sin cliente'));

SELECT count(*) AS total,
       count(json_extract(cuerpo,'$.cliente.ciudad')) AS con_ciudad
FROM documentos;
total  con_ciudad
-----  ----------
877    876

Entró sin una queja. Sin cliente, con un campo total_soles que no existe en ningún otro y con una nota que nadie más tiene.

877 documentos y 876 con ciudad. Ese uno es el que va a romperte el reporte dentro de tres meses, cuando ya nadie se acuerde de por qué está ahí.

Compáralo con una tabla: si id_cliente es NOT NULL con su clave foránea, el mismo intento revienta en el momento, con nombre y apellido. Eso es lo que hacía el capítulo 11 y por eso insistí tanto 🧾

La palabra que se usa para vender esto es esquema flexible. Es verdad, y también es verdad esto otro: el esquema no desaparece, se muda. Deja de estar en la base y pasa a estar repartido en el código de quien lee, en la cabeza de quien lo escribió y en ningún sitio donde se pueda consultar.

Cómo lo hace cada motor

Guardar JSON dentro de una base relacional lo soportan los cuatro, con nombres distintos:

Sacar un campo de dentro de un JSON

PostgreSQLSELECT cuerpo -> 'cliente' ->> 'ciudad' FROM documentos;
MySQLSELECT cuerpo->>'$.cliente.ciudad' FROM documentos;
SQL ServerSELECT JSON_VALUE(cuerpo, '$.cliente.ciudad') FROM documentos;
SQLiteSELECT json_extract(cuerpo, '$.cliente.ciudad') FROM documentos;

La ruta con $ la usan tres de los cuatro. PostgreSQL va por su lado con las flechas, y la doble flecha es la que devuelve texto en vez de JSON.

PostgreSQL es el que más lejos llegó: tiene un tipo jsonb de verdad, con índices propios pensados para esto. Si tu problema es "casi todo son tablas y una cosa es un documento", esa suele ser la respuesta correcta, y te ahorra montar una segunda base 🧾

Se le puede poner freno, y hay que ponerlo a mano

Que nadie te obligue no quiere decir que no puedas obligarte tú. SQLite trae json_valid, y con un CHECK del capítulo 11 ya tienes una puerta:

CREATE TABLE estricta (
  id INTEGER PRIMARY KEY,
  cuerpo TEXT CHECK (json_valid(cuerpo))
);

INSERT INTO estricta (id, cuerpo) VALUES (1, '{"pedido": 1,');
IntegrityError: CHECK constraint failed: json_valid(cuerpo)

Ese JSON estaba cortado a la mitad y la tabla lo rechazó. Mongo tiene lo equivalente, se llama validador de esquema, y allá también es opcional.

Lo que quiero que te lleves: la disciplina que la tabla te daba gratis, en documentos la pones tú o no la pone nadie. Y si vas a ponerla tú entera, la pregunta honesta es por qué no usabas una tabla 🙂

Cuándo cada una

Te conviene una tabla siTe conviene un documento si
los datos tienen forma fijacada cosa trae campos distintos
preguntas cruzando entidadeslees una cosa entera, mucho
hay dinero de por mediohay logs, eventos, catálogos raros
varios sistemas escribenescribe una sola aplicación

Y la respuesta que más se usa en empresas de verdad es las dos: la contabilidad en tablas, y el catálogo de productos, donde una laptop tiene pulgadas y un polo tiene tallas, en documentos.

Ojo con una cosa que se dice mucho y es falsa: NoSQL no quiere decir "sin SQL". Quiere decir "no solo SQL", y de hecho casi todos estos motores acabaron metiendo un lenguaje de consulta que se parece bastante a lo que ya sabes 🙂

Los otros tipos, en dos líneas

  • Documentos: Mongo, y lo de este capítulo.
  • Clave y valor: Redis. Le pides algo por su nombre y te lo da rapidísimo. Se usa para cachés y sesiones, no para reportes.
  • Columnar ancha: Cassandra. Para escribir muchísimo y muy repartido.
  • Grafos: Neo4j. Cuando lo que importa son las relaciones, como quién conoce a quién.

Los cuatro se llaman NoSQL y no se parecen en nada entre ellos. Es una etiqueta de lo que no son, que es la peor manera de nombrar algo.

Lo que te llevas

  • Un documento guarda una cosa entera, con listas dentro.
  • Se consulta con rutas: $.cliente.ciudad.
  • Para preguntas que cruzan documentos hay que desanidar, y eso es volver a las filas.
  • Nadie te obliga a que dos documentos se parezcan, y eso lo pagas después.
  • El esquema no desaparece: se muda de la base al código.
  • NoSQL no es un tipo de base, son cuatro familias distintas.
  • La respuesta habitual en empresas es usar las dos.

Comprueba que se entendió

Comprueba que lo tienes

Metes en tu colección un documento que no trae el campo cliente. ¿Qué pasa?

  • Nada: entra sin quejarse, y el problema aparece cuando alguien lo consulte
  • Da error, porque los otros documentos sí lo traen
  • Se rellena con nulo, como en una tabla
  • Depende del motor: Mongo avisa y SQLite no

Ejercicios

1. El documento raro sigue ahí, encuéntralo

Busca los que no cumplen la forma que tú esperabas.

SELECT id, cuerpo FROM documentos
WHERE json_extract(cuerpo,'$.cliente.ciudad') IS NULL;
id     cuerpo
-----  --------------------------------------------------------------------------
99999  {"pedido":99999,"canal":"WhatsApp","total_soles":250,"nota":"sin cliente"}

Esta consulta es la que en una base de documentos hay que correr a mano cada tanto, porque nadie la corre por ti. En una tabla la haría el motor cada vez que alguien intenta insertar.

2. Qué campos existen de verdad en tu colección

El inventario que debería venir de fábrica.

SELECT k.key AS campo, count(*) AS documentos
FROM documentos d, json_each(d.cuerpo) k
GROUP BY 1 ORDER BY 2 DESC;
campo        documentos
-----------  ----------
pedido       877
canal        877
lineas       876
fecha        876
cliente      876
total_soles  1
nota         1

Léelo como un diagnóstico: pedido y canal están en los 877, y hay dos campos que aparecen una sola vez. Esa tabla es el esquema que la base no te guarda, reconstruido a posteriori. Guárdate esta consulta.

3. Un índice sobre algo que está dentro del JSON

Se puede indexar una expresión, no solo una columna.

CREATE INDEX idx_ciudad_doc
ON documentos (json_extract(cuerpo,'$.cliente.ciudad'));

EXPLAIN QUERY PLAN
SELECT count(*) FROM documentos
WHERE json_extract(cuerpo,'$.cliente.ciudad') = 'Piura';
id  parent  notused  detail
--  ------  -------  -------------------------------------------------------
4   0       0        SEARCH documentos USING INDEX idx_ciudad_doc (=?)

SEARCH y no SCAN, o sea que usó el índice, igual que en el capítulo 14. La condición es que la expresión del índice sea idéntica a la de la consulta: si en el WHERE escribes la ruta de otra manera, el índice no se usa y no te avisa nadie.

4. El precio de cambiar un dato repetido

Un cliente cambia de nombre. Cuenta cuánto hay que tocar de cada lado.

SELECT count(*) AS documentos_a_tocar
FROM documentos
WHERE json_extract(cuerpo,'$.cliente.nombre') =
      (SELECT json_extract(cuerpo,'$.cliente.nombre')
       FROM documentos WHERE id = 1);
documentos_a_tocar
------------------
9

Y ahora del lado de las tablas:

SELECT count(*) AS filas_a_tocar_en_tablas
FROM clientes WHERE id = (SELECT id_cliente FROM pedidos WHERE id = 1);
filas_a_tocar_en_tablas
-----------------------
1

Nueve contra una. Eso es justo lo que la normalización del capítulo 10 venía a evitar, y el documento lo deshace a propósito para ganar velocidad de lectura.

Si olvidas uno de los nueve, tienes el mismo cliente con dos nombres y ningún error. Por eso los documentos van bien cuando lo de dentro no cambia, como el pedido de una fecha concreta, y mal cuando cambia, como los datos de un cliente.

5. Cuántas formas distintas hay en tu colección

El esquema que nadie guarda, reconstruido con una consulta.

SELECT campos, count(*) AS documentos FROM (
  SELECT d.id, group_concat(k.key, ',') AS campos
  FROM documentos d, json_each(d.cuerpo) k
  GROUP BY d.id)
GROUP BY campos ORDER BY 2 DESC;
campos                             documentos
---------------------------------  ----------
pedido,fecha,canal,cliente,lineas  876
pedido,canal,total_soles,nota      1

Dos formas. Con dos se vive; el día que veas veinte, tu colección dejó de tener forma y cada consulta que escribas va a ser una apuesta.

Este es el primer diagnóstico que corro cuando me pasan una base de documentos que no conozco, antes de escribir nada más.

6. Tampoco te garantiza el tipo

Mete una cantidad que no es un número y mira si alguien protesta.

INSERT INTO documentos (id, cuerpo)
VALUES (99998, json_object('pedido', 99998, 'canal', 'Web',
       'cliente', json_object('nombre','X','ciudad','Lima'),
       'lineas', json_array(json_object('producto','Producto 01',
                                        'cantidad','muchas'))));

SELECT json_type(l.value,'$.cantidad') AS tipo, count(*) AS lineas
FROM documentos d, json_each(d.cuerpo,'$.lineas') l
GROUP BY 1;
tipo     lineas
-------  ------
integer  2605
text     1

Una línea con la cantidad escrita como texto, y entró igual. Si mañana sumas esa columna, SQLite trata 'muchas' como cero y tu total sale bajo sin decir nada.

Es el mismo problema del capítulo 4, solo que allá la columna tenía un tipo declarado y acá cada documento decide el suyo. json_type es la herramienta para auditarlo, y hay que acordarse de usarla porque no salta sola.

Practica este capítulo 📓

Todo el código de arriba en un cuaderno que corre de principio a fin, y los ejercicios con una celda vacía para que los hagas tú. Se abre en Google Colab de un clic y no hay que instalar nada. Donde veas %%revisa, escribe tu respuesta y el cuaderno te dice si te salió.

¿Prefieres trabajar en tu máquina? Bájate el cuaderno de práctica o el de soluciones. Todos están también en github.com/soymissyera/MissYeraEjercicios.

¿Le sirve a alguien que conoces?

Pásale el libro. Es gratis, está entero y no pide registro 🐣

Instagram y TikTok no dejan compartir enlaces desde la web: esos dos copian la URL para que la pegues en tu historia.

¿Tienes alguna duda o consulta?