Saltar a contenido

A5.2 JSON y columnas generadas

JSON dentro de una columna

Una tabla relacional tiene las mismas columnas en todas las filas. Pero hay datos cuya forma varía: los atributos de un producto (una camiseta tiene tallas; una gorra, un logo; un libro, un autor). Una solución es guardar esos atributos como JSON en una columna de texto. Para reproducir los ejemplos, ejecuta antes:

CREATE TABLE productos (
  id        INTEGER PRIMARY KEY,
  nombre    TEXT NOT NULL,
  atributos TEXT CHECK (json_valid(atributos))   -- JSON guardado como texto
);
INSERT INTO productos VALUES
  (1, 'Camiseta', '{"color":"rojo","tallas":["S","M","L"],"peso_g":180}'),
  (2, 'Sudadera', '{"color":"azul","tallas":["M","L"],"peso_g":450}'),
  (3, 'Gorra',    '{"color":"rojo","tallas":["U"],"extra":{"logo":true}}');

El CHECK (json_valid(atributos)) hace que solo se acepte JSON bien formado: un texto roto se rechaza al insertarlo.

Extraer valores: -> y ->>

Dos operadores sacan datos de un JSON, indicando la ruta con la sintaxis '$.campo':

  • ->> devuelve el valor como un valor SQL normal (texto, número...). Es el que sueles querer.
  • -> devuelve el valor como JSON (con sus comillas, corchetes...).
SELECT nombre,
       atributos ->> '$.color'            AS color,
       atributos -> '$.tallas'            AS tallas_json,
       atributos ->> '$.tallas[0]'        AS primera_talla,
       json_array_length(atributos, '$.tallas') AS n_tallas
FROM productos;

Resultado:

nombre color tallas_json primera_talla n_tallas
Camiseta rojo ["S","M","L"] S 3
Sudadera azul ["M","L"] M 2
Gorra rojo ["U"] U 1

3 filas

Se pueden recorrer rutas anidadas ('$.extra.logo') y posiciones de una lista ('$.tallas[0]'). Si el campo no existe, el resultado es NULL, sin error: la gorra no tiene peso_g, y no pasa nada:

SELECT nombre,
       atributos ->> '$.peso_g'        AS peso,
       atributos ->> '$.extra.logo'    AS logo
FROM productos;

Resultado:

nombre peso logo
Camiseta 180 NULL
Sudadera 450 NULL
Gorra NULL 1

3 filas

En SQLite hay también la función json_extract(atributos, '$.color'), equivalente a ->>. PostgreSQL (con el tipo jsonb) y MySQL (con el tipo JSON) tienen los mismos operadores -> y ->>, con alguna diferencia de sintaxis en las rutas.

Filtrar y agrupar por un valor del JSON

Un valor extraído se usa como cualquier columna:

SELECT atributos ->> '$.color' AS color, COUNT(*) AS productos
FROM productos
GROUP BY color
ORDER BY color;

Resultado:

color productos
azul 1
rojo 2

2 filas

Una fila por cada elemento de una lista

json_each desdobla una lista JSON en filas, una por elemento (en la columna value). Así se obtiene una fila por cada talla de cada producto, lo que sería una tabla aparte en un diseño relacional:

SELECT p.nombre, t.value AS talla
FROM productos p, json_each(p.atributos, '$.tallas') t
ORDER BY p.id, t.key;

Resultado:

nombre talla
Camiseta S
Camiseta M
Camiseta L
Sudadera M
Sudadera L
Gorra U

6 filas

Modificar y construir JSON

json_set(json, ruta, valor) cambia o añade un campo y devuelve el JSON nuevo, sin tocar el resto. Junto con RETURNING (de la página anterior) se ve el resultado al instante:

UPDATE productos
SET atributos = json_set(atributos, '$.color', 'verde')
WHERE id = 1
RETURNING nombre, atributos;

Resultado:

nombre atributos
Camiseta {"color":"verde","tallas":["S","M","L"],"peso_g":180}

1 fila

Y al revés, json_object y json_group_array construyen JSON desde filas, útil para devolver a una aplicación web un resultado ya en el formato que espera:

SELECT json_group_array(
         json_object('producto', nombre, 'color', atributos ->> '$.color')
       ) AS respuesta
FROM productos;

Resultado:

respuesta
[{"producto":"Camiseta","color":"rojo"},{"producto":"Sudadera","color":"azul"},{"producto":"Gorra","color":"rojo"}]

1 fila

¿JSON o columnas?

Usa columnas cuando Usa JSON cuando
Los datos tienen la misma forma en todas las filas Los datos varían de una fila a otra
Vas a filtrar, ordenar o unir por ellos a menudo Se leen juntos, como un bloque
Necesitas restricciones (NOT NULL, claves foráneas) La estructura cambia con frecuencia
Quieres que la base de datos entienda el dato (tipos, integridad) Guardas lo que te envía otro sistema tal cual

Un JSON no sustituye al diseño de tablas: es una válvula de escape para lo que de verdad es flexible. Si en todas las filas hay un campo color, debería ser una columna.

Columnas generadas

Una columna generada es una columna cuyo valor calcula la propia base de datos a partir de otras de la misma fila; no se puede escribir a mano. Con GENERATED ALWAYS AS (expresión):

  • VIRTUAL (por defecto): no se guarda; se calcula cada vez que se lee. No ocupa espacio.
  • STORED: se guarda al escribir la fila. Ocupa espacio, pero se lee sin calcular.
CREATE TABLE rectangulos (
  id        INTEGER PRIMARY KEY,
  ancho     INTEGER NOT NULL,
  alto      INTEGER NOT NULL,
  area      INTEGER GENERATED ALWAYS AS (ancho * alto)       VIRTUAL,
  perimetro INTEGER GENERATED ALWAYS AS (2 * (ancho + alto)) STORED
);
INSERT INTO rectangulos (ancho, alto) VALUES (3, 4), (5, 2), (1, 9);

SELECT * FROM rectangulos ORDER BY area;

Resultado:

id ancho alto area perimetro
3 1 9 9 20
2 5 2 10 14
1 3 4 12 14

3 filas

area y perimetro siempre están al día: si cambias el ancho de una fila, se recalculan solos, sin que la aplicación tenga que acordarse. Y si intentas escribir en una, SQLite lo rechaza (cannot INSERT into generated column). La diferencia con una vista es que la columna generada vive dentro de la tabla.

Un índice sobre un valor calculado

Una columna generada, o una expresión, se puede indexar. Así se hace rápida la búsqueda por un campo que está dentro de un JSON:

CREATE INDEX idx_color ON productos (atributos ->> '$.color');

EXPLAIN QUERY PLAN
SELECT * FROM productos WHERE atributos ->> '$.color' = 'rojo';

Resultado:

id parent notused detail
3 0 61 SEARCH productos USING INDEX idx_color (=?)

1 fila

El plan dice SEARCH ... USING INDEX idx_color: busca en el índice, sin recorrer los productos. Para que sirva, la consulta debe usar exactamente la misma expresión que el índice (como viste en la A3.2: lo que se transforma en el WHERE tiene que coincidir con lo indexado).

Errores frecuentes

Error Cómo evitarlo
Guardar en JSON datos que todas las filas tienen Haz de ellos una columna
Olvidar json_valid y guardar JSON roto CHECK (json_valid(columna))
Confundir -> con ->> ->> da un valor SQL; -> da JSON (con comillas)
Esperar un error si el campo no existe Da NULL: compruébalo si importa
Intentar escribir en una columna generada Se calcula sola: escribe en las columnas de las que depende
Filtrar por un campo del JSON sin índice en una tabla grande Un índice sobre la misma expresión

Para practicar

Los ejercicios SA5.5 y SA5.6 de SA5 · Ejercicios practican las consultas sobre JSON y las columnas generadas.