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
| PostgreSQL | SELECT cuerpo -> 'cliente' ->> 'ciudad' FROM documentos; |
| MySQL | SELECT cuerpo->>'$.cliente.ciudad' FROM documentos; |
| SQL Server | SELECT JSON_VALUE(cuerpo, '$.cliente.ciudad') FROM documentos; |
| SQLite | SELECT 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 si | Te conviene un documento si |
|---|---|
| los datos tienen forma fija | cada cosa trae campos distintos |
| preguntas cruzando entidades | lees una cosa entera, mucho |
| hay dinero de por medio | hay logs, eventos, catálogos raros |
| varios sistemas escriben | escribe 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.