Todo el curso ha dado por supuesto que sabes qué columnas necesitas. Y casi siempre es cierto: un pedido tiene cliente, fecha, estado y portes, y eso no cambia. Pero hay datos que no tienen forma fija. Un aceite de oliva tiene acidez, variedad de aceituna y método de extracción; una crema tiene ingredientes, tipo de piel y volumen; un cepillo de bambú no tiene ninguna de esas cosas y sí tiene tipo de cerda. Añadir una columna por atributo daría una tabla de ochenta columnas casi todas nulas, y montar una tabla de pares clave-valor —el clásico EAV— convierte cualquier consulta en un rompecabezas de autouniones.

Para eso PostgreSQL tiene JSONB: una columna que guarda un documento con estructura propia, indexable y consultable con SQL. Aquí se cierra la promesa de 08-03 (índices GIN sobre JSON) y se cubre el caso de la respuesta de una API, las preferencias de usuario o el registro de eventos. Verás cómo construir documentos, cómo leerlos, cómo modificarlos, cómo volver a convertirlos en filas para seguir usando todo el SQL del curso, y —lo más importante de la lección— qué no debe ir nunca dentro de un JSON.

Contenido

  1. Cuándo un esquema fijo no basta
  2. JSON frente a JSONB
  3. Construir documentos
  4. La columna productos.atributos de TiendaVerde
  5. Acceder: ->, ->>, #>, #>>
  6. Buscar: contención, existencia y JSONPath
  7. Modificar documentos
  8. Expandir a filas: el puente de vuelta al SQL
  9. Indexación con GIN
  10. La discusión de diseño: qué NO va en JSON
  11. Errores Comunes y Consejos
  12. Ejercicios
  13. Conclusión del módulo

  1. Cuándo un esquema fijo no basta

Cuatro situaciones en las que el modelo relacional puro incomoda:

Situación Por qué duele en columnas Ejemplo en TiendaVerde
Atributos que varían por tipo de elemento Una columna por atributo, casi todas nulas Acidez del aceite, ingredientes de la crema, certificaciones ecológicas
Respuestas de una API externa Su forma la decide otro, y cambia sin avisar Lo que devuelve la pasarela de pago
Preferencias y eventos Cada usuario y cada tipo de evento llevan datos distintos Idioma, avisos; clics, errores, trazas

Y las tres salidas clásicas, con su precio:

Enfoque Ventaja Coste
Una columna por atributo Tipado, restricciones, índices baratos Tabla dispersa; cada atributo nuevo es un ALTER TABLE (05-06)
Tabla EAV (atributo_id, valor) Flexible, sin migraciones Todo es texto; una consulta con tres atributos son tres autouniones
Columna JSONB Flexible y consultable, con índices propios Sin tipado ni integridad referencial; fácil de usar mal

  1. JSON frente a JSONB

PostgreSQL tiene dos tipos, y elegir mal se paga.

JSON JSONB
Cómo se guarda Texto literal, tal cual lo escribiste Binario descompuesto (árbol de claves y valores)
Al escribir Solo valida la sintaxis: muy rápido Analiza y normaliza: algo más lento
Al leer una clave Reanaliza el texto entero cada vez Acceso directo: mucho más rápido
Espacios, formato y orden de claves Se conservan Se pierden: se reordena internamente
Claves duplicadas Se conservan todas Se queda con la última
Operadores @>, ?, @@ / índice GIN No / no /

La regla práctica: usa JSONB salvo que necesites conservar el texto exacto. Y ese "salvo" es muy estrecho: básicamente, guardar la respuesta literal de un servicio externo porque hay que verificar una firma digital o reproducirla byte a byte. Para todo lo demás, JSONB. La diferencia se ve al instante:

SELECT '{"b": 1, "a": 2, "a": 3}'::json  AS como_json,
       '{"b": 1, "a": 2, "a": 3}'::jsonb AS como_jsonb;
como_json como_jsonb
{"b": 1, "a": 2, "a": 3} {"a": 3, "b": 1}

El json guarda el disparate tal cual —clave a repetida incluida—; el jsonb normaliza, ordena y se queda con el último valor de a.

  1. Construir documentos

Función Qué hace Ejemplo
Literal Texto con ::jsonb '{"origen": "España"}'::jsonb
to_jsonb(x) Convierte cualquier valor o fila a JSON to_jsonb(p.*) → el producto entero como objeto
jsonb_build_object(k, v, ...) Objeto con claves y valores alternos jsonb_build_object('id', p.id, 'precio', p.precio)
jsonb_build_array(a, b, ...) Array a partir de valores sueltos jsonb_build_array('bio', 'vegano')
jsonb_agg(expr) Agregado: junta muchas filas en un array Todas las líneas de un pedido
jsonb_object_agg(k, v) Agregado: convierte filas en pares clave-valor {"Alimentación": 256.27, ...}

El caso estrella —devolver un pedido entero con sus líneas anidadas en una sola fila, que es lo que una API necesita— combina los tres últimos:

SELECT jsonb_build_object(
         'pedido_id', pe.id, 'fecha', pe.fecha_pedido, 'portes', pe.gastos_envio,
         'cliente', jsonb_build_object('id', c.id, 'pais', c.pais,
                                       'nombre', c.nombre || ' ' || c.apellidos),
         'lineas',  (SELECT jsonb_agg(jsonb_build_object(
                              'producto', p.nombre, 'cantidad', lp.cantidad,
                              'importe',  ROUND(lp.cantidad * lp.precio_unitario * (1 - lp.descuento), 2))
                            ORDER BY lp.id)
                     FROM lineas_pedido AS lp
                     JOIN productos     AS p ON p.id = lp.producto_id
                     WHERE lp.pedido_id = pe.id)
       ) AS pedido
FROM   pedidos AS pe JOIN clientes AS c ON c.id = pe.cliente_id
WHERE  pe.id = 1;
{"fecha": "2025-03-04", "portes": 4.95, "pedido_id": 1,
 "cliente": {"id": 1, "pais": "España", "nombre": "Lucía Martínez Soler"},
 "lineas": [{"importe": 23.90, "cantidad": 2, "producto": "Aceite de oliva virgen extra 500 ml"},
            {"importe": 11.70, "cantidad": 3, "producto": "Arroz integral ecológico 1 kg"},
            {"importe": 6.50,  "cantidad": 2, "producto": "Infusión de manzanilla ecológica 20 uds"}]}

Una fila, una columna, el pedido 1 completo con sus 42,10 € en tres líneas. Fíjate en dos cosas: jsonb_agg admite su propio ORDER BY dentro de los paréntesis, y las claves salen desordenadas respecto a como las escribiste, porque es jsonb. Esto ahorra a la aplicación el trabajo de reensamblar filas planas en un objeto anidado, y es la razón por la que muchas APIs modernas devuelven directamente lo que produce la base de datos — el enlace con 11-05.

Y para un resumen compacto, jsonb_object_agg:

-- Ojo: un agregado no puede anidarse dentro de otro, así que primero se agrupa (10-02)
WITH por_categoria AS (
    SELECT cat.nombre, ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2) AS total
    FROM   lineas_pedido AS lp
    JOIN   productos  AS p   ON p.id   = lp.producto_id
    JOIN   categorias AS cat ON cat.id = p.categoria_id
    GROUP  BY cat.nombre)
SELECT jsonb_object_agg(nombre, total) AS facturacion FROM por_categoria;
{"Bebidas": 195.28, "Alimentación": 256.27, "Hogar sostenible": 88.58,
 "Higiene personal": 31.50, "Cosmética natural": 156.32}

Las cinco categorías con ventas y sus cifras canónicas, en un solo valor.

  1. La columna productos.atributos de TiendaVerde

A partir de aquí trabajamos con una columna nueva. No es canónica: es el ejemplo de esta lección y ningún otro módulo la usa.

-- ⚠️ NO CANÓNICA: columna de ejemplo de 10-06. Recarga el script de 01-06 al terminar.
ALTER TABLE productos ADD COLUMN atributos JSONB;

UPDATE productos SET atributos = '{"origen":"España","acidez":0.3,"variedad":"picual",
  "extraccion":"en frío","certificaciones":["ecologico-ue","sin-gluten"]}'::jsonb WHERE id = 1;
UPDATE productos SET atributos = '{"origen":"España","tipo_grano":"integral","certificaciones":["ecologico-ue"]}' WHERE id = 2;
UPDATE productos SET atributos = '{"origen":"Francia","volumen_ml":50,"tipo_piel":"seca",
  "ingredientes":["aloe vera","aceite de jojoba"],"certificaciones":["cosmos-organic","vegano"]}'::jsonb
  WHERE id = 6;
UPDATE productos SET atributos = '{"origen":"Portugal","volumen_ml":200,"tipo_piel":"normal",
  "ingredientes":["almendra dulce","vitamina E"],"certificaciones":["vegano"]}'::jsonb WHERE id = 8;
UPDATE productos SET atributos = '{"origen":"Portugal","grado":"ceremonial","gramos":30,"certificaciones":["ecologico-ue","vegano"]}' WHERE id = 15;
UPDATE productos SET atributos = '{"origen":"Alemania","material":"bambú","cerdas":"nailon suave","certificaciones":["vegano"]}' WHERE id = 18;

Seis productos con atributos; los otros catorce tienen atributos a NULL, que es lo normal en este tipo de columna y algo que las consultas tendrán que tener en cuenta.

  1. Acceder: ->, ->>, #>, #>>

Cuatro operadores, y la diferencia entre ellos es la fuente de errores número uno con JSON en PostgreSQL:

Operador Argumento Devuelve Ejemplo sobre el producto 1
-> Clave (texto) o índice (entero) jsonb atributos -> 'origen'"España" (con comillas)
->> Clave o índice text atributos ->> 'origen'España
#> Ruta: array de texto jsonb atributos #> '{certificaciones,0}'"ecologico-ue"
#>> Ruta text atributos #>> '{certificaciones,0}'ecologico-ue

La regla mnemotécnica: la flecha doble >> saca el valor "en crudo", como texto. La simple sigue devolviendo JSON, y eso permite encadenar: atributos -> 'certificaciones' ->> 0 baja al array con -> y saca el primer elemento como texto con ->>.

SELECT id, nombre,
       atributos ->  'origen'          AS origen_jsonb,
       atributos ->> 'origen'          AS origen_texto,
       (atributos ->> 'volumen_ml')::int AS volumen_ml,
       atributos -> 'certificaciones'  AS certificaciones,
       atributos #>> '{certificaciones,0}' AS primera_cert
FROM   productos
WHERE  atributos IS NOT NULL
ORDER  BY id;
id nombre origen_jsonb origen_texto volumen_ml certificaciones primera_cert
1 Aceite de oliva virgen extra 500 ml "España" España (null) ["ecologico-ue", "sin-gluten"] ecologico-ue
2 Arroz integral ecológico 1 kg "España" España (null) ["ecologico-ue"] ecologico-ue
6 Crema facial de aloe vera 50 ml "Francia" Francia 50 ["cosmos-organic", "vegano"] cosmos-organic
8 Aceite corporal de almendras 200 ml "Portugal" Portugal 200 ["vegano"] vegano
15 Té verde matcha ceremonial 30 g "Portugal" Portugal (null) ["ecologico-ue", "vegano"] ecologico-ue
18 Cepillo de dientes de bambú "Alemania" Alemania (null) ["vegano"] vegano

Tres cosas que hay que ver en esa tabla. La primera: "España" con comillas no es España; comparar atributos -> 'origen' = 'España' falla porque a la izquierda hay un jsonb y a la derecha un texto — hay que usar ->>, o comparar contra '"España"'::jsonb. La segunda: todo lo que sale de ->> es texto y hay que convertirlo (::int, ::numeric) para comparar como número; si no, '200' < '50' es cierto porque compara alfabéticamente. La tercera: una clave que no existe devuelve NULL, no un error — cómodo, y peligroso, porque una clave mal escrita no se queja.

  1. Buscar: contención, existencia y JSONPath

Los operadores anteriores extraen; estos filtran, y son los que aprovechan el índice GIN.

Operador Lee "…" Ejemplo
@> contiene atributos @> '{"origen":"España"}'
<@ está contenido en '{"origen":"España"}' <@ atributos
? existe la clave (o el elemento, en un array) atributos ? 'acidez'
?| / ?& existe alguna / todas estas claves atributos ?& ARRAY['origen','certificaciones']
SELECT id, nombre, atributos ->> 'origen' AS origen
FROM   productos
WHERE  atributos @> '{"certificaciones": ["vegano"]}'
ORDER  BY id;
id nombre origen
6 Crema facial de aloe vera 50 ml Francia
8 Aceite corporal de almendras 200 ml Portugal
15 Té verde matcha ceremonial 30 g Portugal
18 Cepillo de dientes de bambú Alemania

Cuatro productos veganos. Fíjate en la potencia de @>: busca dentro de un array sin desmontarlo y sin saber en qué posición está el elemento. Y SELECT id FROM productos WHERE atributos ? 'volumen_ml' devuelve 2 filas —los productos 6 y 8, los únicos con esa clave—, que es cómo se pregunta "qué productos tienen definido este atributo".

JSONPath

Para condiciones que @> no puede expresar —comparaciones numéricas, filtros dentro de arrays, expresiones— PostgreSQL 12 añadió JSONPath, un lenguaje de rutas al estilo XPath:

Operador / función Qué hace
@? ¿La ruta encuentra algo? Devuelve booleano
@@ ¿La expresión JSONPath es cierta?
jsonb_path_query(doc, ruta) Devuelve todos los valores que casan, como filas
jsonb_path_query_first / _array El primero / todos en un array
SELECT id, nombre FROM productos WHERE atributos @? '$.volumen_ml ? (@ > 100)';  -- 1
SELECT DISTINCT jsonb_path_query(atributos, '$.certificaciones[*]') #>> '{}' AS certificacion
FROM   productos WHERE atributos IS NOT NULL ORDER BY 1;                        -- 2

La primera devuelve una fila, el producto 8 (aceite corporal, 200 ml): el 6 tiene 50 y el resto no tiene la clave. La segunda devuelve cinco: cosmos-organic, ecologico-ue, sin-gluten, vegano y —al añadir cualquier producto nuevo— lo que sea que traiga. Lee $.volumen_ml ? (@ > 100) así: $ es la raíz del documento, .volumen_ml baja a esa clave, ? (...) es un filtro y @ es el valor actual. Con [*] se recorren todos los elementos de un array.

  1. Modificar documentos

Operación Cómo Ejemplo
Fusionar || atributos || '{"stock_min": 10}'::jsonb — añade o pisa las claves que coincidan
Borrar clave - con texto atributos - 'acidez'
Borrar varias / por ruta - con array / #- atributos - ARRAY['acidez','variedad'], atributos #- '{certificaciones,1}'
Fijar un valor jsonb_set(doc, ruta, valor [, crear]) Cambia si existe; con el 4.º argumento a true (por omisión) lo crea
Insertar en un array jsonb_insert(doc, ruta, valor [, después]) Añade sin pisar; falla si la ruta ya existe
UPDATE productos
SET    atributos = jsonb_set(atributos, '{acidez}', '0.25'::jsonb)
                   || '{"revisado": true}'::jsonb
WHERE  id = 1;

SELECT atributos ->> 'acidez' AS acidez, atributos ->> 'revisado' AS revisado
FROM   productos WHERE id = 1;
acidez revisado
0.25 true

Y aquí el detalle de rendimiento que hay que interiorizar: un UPDATE sobre una columna JSONB reescribe el documento entero. No existe "actualizar una clave"; PostgreSQL crea una versión nueva de la fila completa con el documento completo (es MVCC, 09-02). Cambiar un booleano en un documento de 200 KB escribe 200 KB. La consecuencia práctica: JSONB es para escribir poco y leer mucho. Si un campo se actualiza constantemente, ese campo quiere ser una columna.

Una nota fina: jsonb_set con un NULL de SQL en cualquier argumento devuelve NULL, no el documento sin tocar — y como es un UPDATE, borra el documento entero sin avisar. Usa COALESCE o jsonb_set(COALESCE(atributos, '{}'::jsonb), ...) cuando la columna pueda ser nula.

  1. Expandir a filas: el puente de vuelta al SQL

Este apartado es el que conecta JSON con todo lo demás del curso: convertir un documento en filas para poder agrupar, unir, ordenar y aplicarle funciones de ventana.

Función Convierte En
jsonb_array_elements(doc) Un array JSON Una fila por elemento (jsonb)
jsonb_array_elements_text(doc) Un array JSON Una fila por elemento (text)
jsonb_each(doc) / jsonb_object_keys(doc) Un objeto Una fila por par (key, value) / por clave
jsonb_to_record / jsonb_to_recordset Objeto / array de objetos Filas con columnas tipadas
SELECT cert.valor AS certificacion, COUNT(*) AS productos
FROM   productos AS p
CROSS JOIN LATERAL jsonb_array_elements_text(p.atributos -> 'certificaciones') AS cert(valor)
WHERE  p.atributos ? 'certificaciones'
GROUP  BY cert.valor
ORDER  BY productos DESC, certificacion;
certificacion productos
vegano 4
ecologico-ue 3
cosmos-organic 1
sin-gluten 1

Y aquí ya no hay nada de JSON: es un GROUP BY del módulo 4 sobre filas normales, con el LATERAL de 07-04 haciendo de puente. Ese es exactamente el punto: una vez expandido, un documento es una tabla más, y todo lo que has aprendido en once lecciones vuelve a aplicarse. jsonb_to_recordset va un paso más allá y produce columnas tipadas directamente:

SELECT * FROM jsonb_to_recordset('[{"producto_id":1,"cantidad":2},{"producto_id":15,"cantidad":1}]'::jsonb)
              AS t(producto_id INTEGER, cantidad INTEGER);
producto_id cantidad
1 2
15 1

Es la forma canónica de recibir una lista de líneas de pedido desde una API y convertirla en filas insertables con un solo INSERT ... SELECT — mucho más limpio que los dos arrays paralelos de sp_confirmar_pedido en 10-04.

  1. Indexación con GIN

Aquí se cierra la promesa de 08-03. Sin índice, cada consulta con @> recorre la tabla entera y analiza cada documento. Con un índice GIN, no.

CREATE INDEX ix_productos_atributos     ON productos USING GIN (atributos);                -- jsonb_ops
CREATE INDEX ix_productos_atributos_pth ON productos USING GIN (atributos jsonb_path_ops); -- jsonb_path_ops
jsonb_ops (por omisión) jsonb_path_ops
Qué indexa Cada clave y cada valor por separado Un hash de la ruta completa hasta el valor
Operadores que soporta @>, <@, ?, ?|, ?&, @?, @@ Solo @>, @? y @@
Tamaño / velocidad con @> Mayor / buena Bastante menor / mejor

El criterio: si solo consultas por contención (@>), jsonb_path_ops, que es más pequeño y más rápido. Si necesitas preguntar por existencia de claves (?), no queda más remedio que jsonb_ops. Y recuerda de 08-03 que un GIN se construye y se mantiene despacio: es el índice de "escribe poco, lee mucho", que casualmente es también el perfil de una columna JSONB.

Y la tercera vía, que muchas veces es la mejor: un B-tree sobre una expresión cuando la consulta va siempre por la misma clave.

CREATE INDEX ix_productos_origen ON productos ((atributos ->> 'origen'));
SELECT id, nombre FROM productos WHERE atributos ->> 'origen' = 'Portugal';

Devuelve los productos 8 y 15. Un B-tree sobre (atributos ->> 'origen') es minúsculo comparado con un GIN, soporta rangos y ordenación, y es lo correcto cuando una clave concreta se consulta mucho. Los paréntesis dobles son obligatorios: es un índice de expresión, de los de 08-02 — y funciona porque ->> es IMMUTABLE, la condición que 10-04 explicó.

  1. La discusión de diseño: qué NO va en JSON

Lo más importante de la lección. JSONB es tan cómodo que invita a meterlo todo dentro, y esa es una decisión que se paga durante años.

Lo que NO debe ir en un JSON:

  • Lo que se filtra o se une constantemente. categoria_id dentro de un JSON convierte un JOIN de índice en una consulta que hay que reescribir con ->> y castear en cada uso.
  • Lo que tiene integridad referencial. Una clave foránea no puede apuntar dentro de un documento. Si guardas {"proveedor_id": 7} y borran el proveedor 7, nadie te avisa: acabas de perder la garantía que 01-05 te daba gratis.
  • Lo que en realidad es una tabla. Un array de mil pedidos dentro del documento de un cliente es una tabla pedidos disfrazada, sin índices propios, sin poder consultarse por sí sola y reescrita entera con cada compra.
  • Lo que tiene reglas. No hay NOT NULL, ni UNIQUE, ni CHECK dentro de un JSON: un precio en JSON puede llegar a ser "caro" y nada lo impedirá. Y lo que se actualiza constantemente, porque cada UPDATE reescribe el documento entero (apartado 7).

El antipatrón: usar la base de datos relacional como almacén de documentos por pereza de modelar. Se reconoce por una tabla con id y una columna datos JSONB que lo contiene todo. Funciona el primer mes, y a partir del sexto cada consulta es un castillo de ->> y ::numeric, nada tiene índice, nada tiene integridad y nadie sabe qué claves existen. Si sabes qué campos hay, son columnas. Si de verdad necesitas un almacén de documentos, existen bases de datos que hacen eso mucho mejor.

Y la tabla que resuelve la duda concreta:

Pregunta Si la respuesta es sí →
¿Lo tienen todas las filas y sabes qué es? Columna
¿Se filtra, se ordena o se une con frecuencia? Columna
¿Necesita NOT NULL, UNIQUE, CHECK o una FK? ¿Se actualiza a menudo por separado? Columna
¿Es una lista de entidades con vida propia? ¿Hay que consultarlo o agregarlo por sí solo? Tabla relacionada
¿Varía por tipo de fila y solo se lee junto al resto? ¿Viene de fuera con forma que no controlas? JSON
¿Es opcional, disperso y de baja frecuencia de uso? JSON

En TiendaVerde, aplicado: precio, stock y categoria_id son columnas, sin discusión; las líneas de pedido son una tabla, no un array dentro de pedidos; y la acidez del aceite o el tipo de piel de una crema son JSON, porque cada categoría tiene los suyos y solo se muestran en la ficha del producto.

Nota de dialecto: MySQL 8 tiene un tipo JSON binario con -> y ->> (con la misma semántica de PostgreSQL), JSON_EXTRACT, JSON_TABLE para expandir a filas, y no tiene índices sobre JSON: se indexan columnas generadas. SQLite trae la extensión JSON1 compilada por omisión: json_extract(), json_each(), el operador ->> desde la 3.38, y todo guardado como texto. SQL Server almacena JSON en NVARCHAR y lo consulta con JSON_VALUE, JSON_QUERY y OPENJSON para expandir a filas, con índices sobre columnas calculadas. Oracle tiene un tipo JSON nativo desde 21c y soporta JSONPath ampliamente. El estándar SQL:2016 define JSONPath, y por eso jsonb_path_query se parece tanto entre motores; lo demás es dialecto puro.

Errores Comunes y Consejos

  • Confundir -> con ->>. El primero devuelve jsonb ("España", con comillas) y el segundo text (España). atributos -> 'origen' = 'España' no casa nunca.
  • Comparar números sin convertir. atributos ->> 'volumen_ml' > '100' compara texto: '50' es mayor que '100'. Castea siempre: (atributos ->> 'volumen_ml')::int.
  • Escribir mal una clave. No da error: devuelve NULL y la fila desaparece en silencio. Comprueba con ? qué claves existen de verdad. Y usar JSON en lugar de JSONB: Sin operadores de contención, sin índices GIN y reanalizando el texto en cada acceso. JSONB salvo que necesites el texto literal exacto.
  • Olvidar que un UPDATE reescribe el documento entero. En documentos grandes y actualizaciones frecuentes, eso es bloat y trabajo de VACUUM (09-02).
  • jsonb_set sobre una columna NULL. Devuelve NULL y borra el documento. COALESCE(atributos, '{}'::jsonb).
  • Indexar con GIN por defecto y no medir. Si solo usas @>, jsonb_path_ops ocupa bastante menos; y si siempre consultas la misma clave, un B-tree sobre la expresión gana a los dos.
  • Meter en JSON algo que tiene clave foránea. No hay integridad referencial dentro de un documento, y no la habrá nunca. Consejo: documenta las claves esperadas. Un JSON sin esquema documentado es un campo de texto libre. Si el conjunto de claves es cerrado, valídalo con un CHECK (atributos ?& ARRAY['origen']) o con la extensión de validación de esquemas.
  • Consejo: empieza por columnas y mueve a JSON solo lo que sobre. Al revés no funciona: sacar de un JSON tres años de datos a columnas tipadas es una migración cara.
  • Consejo: jsonb_pretty(atributos) para leer un documento en psql. Y \x para el modo expandido.

Ejercicios

Ejercicio 1

Con la columna atributos cargada como en el apartado 4: (1) Lista los productos de origen español con su acidez, si la tienen. (2) Cuenta cuántos productos hay por origen, ordenados de más a menos. (3) Encuentra todos los que tienen aloe vera entre sus ingredientes, usando @>. (4) Añade la clave "revisado": false a todos los productos que tengan atributos, sin pisar nada de lo que ya hay.

Ejercicio 2

Marketing quiere el catálogo en JSON para la web: un array con un objeto por categoría, y dentro de cada uno, el nombre de la categoría y el array de sus productos activos con id, nombre y precio. Escríbelo con jsonb_agg y jsonb_build_object, y di cuántos elementos tiene el array exterior.

Ejercicio 3

Decide, con la tabla del apartado 10, dónde va cada dato y justifica en una frase:

  1. La valoración media de un producto, que se muestra en el listado y permite ordenar.
  2. Las dimensiones del embalaje (alto, ancho, fondo), que solo conocen algunos productos y solo se usan al calcular portes.
  3. El historial de cambios de precio de un producto.
  4. La respuesta completa de la pasarela de pago al cobrar un pedido.
  5. El proveedor de un producto.

Soluciones

Solución 1

-- 1
SELECT id, nombre, (atributos ->> 'acidez')::numeric AS acidez
FROM   productos WHERE atributos @> '{"origen":"España"}' ORDER BY id;
-- 2
SELECT atributos ->> 'origen' AS origen, COUNT(*) AS productos
FROM   productos WHERE atributos ? 'origen' GROUP BY 1 ORDER BY productos DESC, origen;
-- 3
SELECT id, nombre FROM productos WHERE atributos @> '{"ingredientes":["aloe vera"]}';
-- 4
UPDATE productos SET atributos = '{"revisado": false}'::jsonb || atributos
WHERE  atributos IS NOT NULL;

1 devuelve dos filas: el aceite (id 1) con acidez 0.25 —la que dejó el jsonb_set del apartado 7— y el arroz (id 2) con NULL, porque no tiene esa clave. 2 devuelve España 2, Portugal 2, Alemania 1 y Francia 1: cuatro orígenes para los seis productos con atributos. 3 devuelve una fila, la crema facial (id 6), y funciona porque @> busca dentro del array sin importar la posición. 4 afecta a 6 filas, y el orden del || es la clave: '{"revisado": false}' || atributos hace que gane atributos si la clave ya existiera, mientras que atributos || '{"revisado": false}' la pisaría. Es la diferencia entre "añade si falta" y "fuerza el valor".

Solución 2

SELECT jsonb_agg(jsonb_build_object(
           'categoria', cat.nombre,
           'productos', (SELECT COALESCE(jsonb_agg(jsonb_build_object(
                                   'id', p.id, 'nombre', p.nombre, 'precio', p.precio)
                                 ORDER BY p.id), '[]'::jsonb)
                         FROM productos AS p
                         WHERE p.categoria_id = cat.id AND p.activo)
       ) ORDER BY cat.id) AS catalogo
FROM   categorias AS cat;

El array exterior tiene 6 elementos, uno por categoría, incluida Complementos — que aparece con "productos": [] porque su único producto, las Cápsulas de espirulina, está descatalogado (activo = FALSE). Ese COALESCE(..., '[]'::jsonb) no es un adorno: sin él, jsonb_agg sobre cero filas devuelve NULL y la clave saldría como null en lugar de como array vacío, lo que rompería cualquier código que recorra la lista. Es exactamente el problema de 04-04 con SUM sobre el conjunto vacío, ahora en JSON.

Solución 3

# Dato Dónde Por qué
1 Valoración media Columna (o vista/materializada) Se ordena y filtra por ella en el listado: dentro de un JSON no tendría índice útil
2 Dimensiones del embalaje JSON Opcionales, dispersas y solo se leen junto al resto del producto al calcular portes
3 Historial de precios Tabla relacionada Es una lista de entidades con vida propia, que hay que consultar y agregar por sí sola: es la auditoria_precios de 10-05
4 Respuesta de la pasarela JSON Viene de fuera con una forma que no controlas y que puede cambiar sin avisar. Aquí incluso cabe JSON en vez de JSONB, si hay que verificar una firma sobre el texto exacto
5 Proveedor Columna con FK Tiene integridad referencial: dentro de un documento, ON DELETE RESTRICT no existe

Las cinco respuestas salen de aplicar tres preguntas: ¿se filtra u ordena por ello? (columna), ¿tiene vida propia? (tabla), ¿es opcional, variable y de solo lectura conjunta? (JSON).

Conclusión del módulo

Cierras el módulo con la última pieza de la caja de herramientas:

  • JSONB frente a JSON: binario descompuesto frente a texto literal. JSONB normaliza claves, elimina duplicados, accede rápido y es el único indexable y con operadores de contención. La regla: JSONB salvo que necesites el texto exacto.
  • Construir: to_jsonb, jsonb_build_object, jsonb_build_array, y sobre todo los agregados jsonb_agg y jsonb_object_agg, que devuelven el pedido 1 completo con sus tres líneas y sus 42,10 € en una sola fila — el caso estrella para una API (11-05).
  • Acceder: -> y #> devuelven jsonb; ->> y #>> devuelven text. Esa diferencia es la fuente de errores número uno, junto con comparar números sin convertir y con las claves inexistentes que devuelven NULL en silencio.
  • Buscar: @> (contención, que entra dentro de los arrays), ?, ?| y ?& (existencia de claves), y JSONPath con @?, @@ y jsonb_path_query para lo que la contención no puede expresar, como $.volumen_ml ? (@ > 100).
  • Modificar: || fusiona, - borra, jsonb_set fija y jsonb_insert añade — recordando que un UPDATE reescribe el documento entero, y que por eso JSONB es para escribir poco y leer mucho.
  • Expandir a filas con jsonb_array_elements, jsonb_each y jsonb_to_recordset es el puente de vuelta: una vez expandido, un documento es una tabla más y vuelve a servir todo el SQL del curso — como el recuento de certificaciones, con vegano en 4 productos y ecologico-ue en 3.
  • Indexar, cerrando 08-03: GIN con jsonb_ops soporta todos los operadores y ocupa más; jsonb_path_ops solo @>, @? y @@, pero es más pequeño y más rápido; y un B-tree sobre (atributos ->> 'clave') gana a los dos cuando siempre consultas la misma clave.
  • Y la discusión de diseño: fuera del JSON todo lo que se filtre o se una, todo lo que tenga integridad referencial, todo lo que sea en realidad una tabla, todo lo que necesite reglas y todo lo que se actualice constantemente. El antipatrón —usar una base relacional como almacén de documentos por pereza de modelar— se paga con intereses. Si sabes qué campos hay, son columnas.

Y con esto se cierra el módulo 10. En seis lecciones has montado la caja de herramientas completa: vistas que dan nombre a una consulta y encapsulan una métrica, con las materializadas para los informes caros; CTE que convierten tres niveles de subconsultas en pasos legibles, y WITH RECURSIVE para recorrer el organigrama entero y la cadena de referidos de tres saltos; funciones de ventana que agregan sin colapsar y resuelven rankings, acumulados, medias móviles y top N por grupo; procedimientos donde vive por fin la confirmación de pedido con su atomicidad garantizada; triggers que aplican solos las reglas que un CHECK no alcanza; y JSON para lo que no cabe en un esquema fijo. Ya no estás aprendiendo SQL: lo estás usando.

Lo que falta no son más funciones, sino contexto. Un sistema real no es una consulta bien escrita: es un conjunto de decisiones sobre cómo se organiza el trabajo, quién puede ver qué, qué se mide y desde dónde se llama. En el módulo 11, Práctica: casos de uso reales, verás SQL en su entorno: los casos de uso que aparecen una y otra vez en cualquier proyecto; las mejores prácticas de escritura, nomenclatura y mantenimiento que separan una base con la que se puede trabajar de una que da miedo tocar; la seguridad —la inyección SQL y cómo se evita de verdad, los permisos y los roles que este módulo ha ido remitiendo lección tras lección—; el SQL para análisis de datos, donde las funciones de ventana de 10-03 se convierten en informes completos; y el SQL en el desarrollo web, con los ORM, el pool de conexiones y el problema N+1. La caja de herramientas ya está llena; queda aprender el oficio.

Curso de SQL

Módulo 1: Introducción a SQL

Módulo 2: Consultas básicas de SQL

Módulo 3: Trabajando con múltiples tablas

Módulo 4: Filtrado avanzado de datos

Módulo 5: Manipulación de datos

Módulo 6: Funciones avanzadas de SQL

Módulo 7: Subconsultas y consultas anidadas

Módulo 8: Índices y optimización de rendimiento

Módulo 9: Transacciones y concurrencia

Módulo 10: Temas avanzados

Módulo 11: SQL en la práctica

Módulo 12: Proyecto final

© Copyright 2026. Todos los derechos reservados