tutoriales.com

Explorando las Tablas No Relacionales en PostgreSQL: JSONB y HSTORE para Datos Flexibles

Este tutorial te guiará a través del uso de las características de PostgreSQL para manejar datos no relacionales, específicamente con los tipos de datos JSONB y HSTORE. Aprenderás a almacenar, consultar y manipular estructuras de datos flexibles directamente en tu base de datos relacional, combinando lo mejor de ambos mundos.

Intermedio15 min de lectura23 views
Reportar error

Introducción: Más allá de lo Relacional con PostgreSQL 🚀

PostgreSQL, conocido por su robustez y cumplimiento estricto de los estándares relacionales, ha evolucionado para abrazar la flexibilidad de los datos no relacionales. Esta capacidad de manejar datos semiestructurados o no estructurados directamente dentro de un entorno relacional es una de sus características más potentes y subestimadas.

Tradicionalmente, las bases de datos relacionales se basan en esquemas fijos, donde cada columna tiene un tipo de dato predefinido. Sin embargo, en el mundo moderno del desarrollo de software, a menudo nos encontramos con la necesidad de almacenar información con estructuras variables, atributos dinámicos o datos que no encajan fácilmente en un modelo tabular estricto. Aquí es donde JSONB y HSTORE entran en juego, ofreciendo la flexibilidad de una base de datos de documentos o clave-valor, pero con el poder transaccional y la fiabilidad de PostgreSQL.

Este tutorial explorará en profundidad cómo utilizar estos tipos de datos para potenciar tus aplicaciones, permitiéndote construir sistemas más ágiles y adaptables a los requisitos cambiantes.

¿Por qué usar JSONB y HSTORE? 🤔

Los tipos de datos JSONB y HSTORE en PostgreSQL ofrecen varias ventajas:

  • Flexibilidad de Esquema: Almacena datos con estructuras cambiantes sin necesidad de migraciones de esquema complejas.
  • Rendimiento: JSONB está optimizado para consultas y almacenamiento eficiente, mientras que HSTORE es ideal para pares clave-valor simples.
  • Integración: Combina la flexibilidad no relacional con las características relacionales de PostgreSQL (transacciones, JOINs, etc.).
  • Potencia de Consulta: PostgreSQL ofrece un conjunto rico de operadores y funciones para consultar y manipular estos tipos de datos.

HSTORE: El Almacén Clave-Valor Sencillo y Eficiente 🔑

HSTORE es un tipo de datos que almacena conjuntos de pares clave-valor dentro de un solo valor de PostgreSQL. Todas las claves y valores son cadenas de texto. Es ideal para datos semiestructurados simples donde necesitas atributos arbitrarios.

Instalación y Activación 🛠️

Antes de usar HSTORE, debes activarlo en tu base de datos. Es una extensión, por lo que se carga de forma externa.

CREATE EXTENSION IF NOT EXISTS hstore;
📌 Nota: Esta extensión solo necesita ser creada una vez por cada base de datos donde desees usarla.

Creando una Tabla con HSTORE 📋

Imaginemos que tenemos una tabla de productos y queremos almacenar atributos adicionales que varían mucho de un producto a otro (ej. dimensiones, color, material, etc.).

CREATE TABLE productos_hstore (
    id SERIAL PRIMARY KEY,
    nombre VARCHAR(255) NOT NULL,
    precio DECIMAL(10, 2) NOT NULL,
    atributos HSTORE
);

Insertando Datos en HSTORE ✅

Los valores HSTORE se representan como una lista de pares clave-valor separados por comas y delimitados por =>.

INSERT INTO productos_hstore (nombre, precio, atributos) VALUES
('Laptop X1', 1200.00, 'pantalla=>15.6", ram=>16GB, ssd=>512GB'),
('Smartphone S20', 800.00, 'color=>negro, camara=>64MP, bateria=>4000mAh'),
('Monitor Ultra', 300.00, 'tamano=>27", resolucion=>4K');

INSERT INTO productos_hstore (nombre, precio, atributos) VALUES
('Teclado Mecánico', 90.00, 'tipo=>mecanico, retroiluminacion=>RGB, marca=>HyperX');

Consultando Datos HSTORE 🔍

PostgreSQL ofrece una rica serie de operadores y funciones para consultar datos HSTORE.

Accediendo a Valores Específicos

Usa el operador -> para obtener un valor por su clave.

SELECT nombre, atributos->'ram' AS ram_laptop
FROM productos_hstore
WHERE nombre = 'Laptop X1';
-- Resultado: |nombre      |ram_laptop|
--            |------------|----------|
--            |Laptop X1   |16GB      |

Buscando por Existencia de Clave o Valor

  • ?: Verifica si una clave existe.
  • ?&: Verifica si todas las claves de una lista existen.
  • ?|: Verifica si al menos una de las claves de una lista existe.
-- Productos con 'color' como atributo
SELECT nombre, atributos
FROM productos_hstore
WHERE atributos ? 'color';

-- Productos con 'ram' Y 'ssd' como atributos
SELECT nombre, atributos
FROM productos_hstore
WHERE atributos ?& ARRAY['ram', 'ssd'];

-- Productos con 'tamano' O 'color' como atributo
SELECT nombre, atributos
FROM productos_hstore
WHERE atributos ?| ARRAY['tamano', 'color'];

Comparando HSTOREs

  • @>: ¿El HSTORE de la izquierda contiene todos los pares clave-valor del HSTORE de la derecha?
  • <@: ¿El HSTORE de la derecha contiene todos los pares clave-valor del HSTORE de la izquierda?
-- Productos que tienen al menos 'color'='negro'
SELECT nombre, atributos
FROM productos_hstore
WHERE atributos @> 'color=>negro';

-- Productos con 'tipo'='mecanico' y 'marca'='HyperX'
SELECT nombre, atributos
FROM productos_hstore
WHERE atributos @> 'tipo=>mecanico, marca=>HyperX';

Actualizando Datos HSTORE 🔄

Puedes actualizar HSTOREs combinándolos, añadiendo nuevos pares o eliminando existentes.

Añadiendo o Modificando Pares

Usa el operador || para combinar HSTOREs. Si una clave ya existe, su valor será sobrescrito por el segundo HSTORE.

UPDATE productos_hstore
SET atributos = atributos || 'peso=>1.8kg'
WHERE nombre = 'Laptop X1';

-- Ahora 'Laptop X1' tendrá 'peso' y si 'ram' se actualiza, el nuevo valor de 'ram' tomará precedencia
UPDATE productos_hstore
SET atributos = atributos || 'ram=>32GB'
WHERE nombre = 'Laptop X1';

SELECT nombre, atributos FROM productos_hstore WHERE nombre = 'Laptop X1';
-- Resultado: |nombre     |atributos                                       |
--            |-----------|------------------------------------------------|
--            |Laptop X1  |"ssd"=>"512GB", "ram"=>"32GB", "peso"=>"1.8kg", "pantalla"=>"15.6"|

Eliminando Pares

Usa el operador - para eliminar claves específicas.

UPDATE productos_hstore
SET atributos = atributos - 'pantalla'
WHERE nombre = 'Laptop X1';

SELECT nombre, atributos FROM productos_hstore WHERE nombre = 'Laptop X1';
-- Resultado: |nombre     |atributos                                       |
--            |-----------|------------------------------------------------|
--            |Laptop X1  |"ssd"=>"512GB", "ram"=>"32GB", "peso"=>"1.8kg"|

Convertir HSTORE a Texto y Viceversa ↔️

Para interactuar con aplicaciones que no soportan HSTORE directamente, puedes convertirlo a text y viceversa.

SELECT CAST(atributos AS text) FROM productos_hstore WHERE nombre = 'Laptop X1';
-- Resultado: "ssd"=>"512GB", "ram"=>"32GB", "peso"=>"1.8kg"

-- Convertir un texto a hstore
SELECT 'a=>1,b=>2'::hstore;

JSONB: El Poder de los Documentos JSON en PostgreSQL 📄

JSONB (JSON Binary) es una forma binaria de almacenar datos JSON. A diferencia de JSON (que almacena el texto JSON tal cual), JSONB procesa el JSON al insertarlo y lo almacena en un formato binario optimizado. Esto lo hace mucho más rápido para consultas y manipulación, aunque un poco más lento en la inserción inicial.

¿JSON vs JSONB? 🤔

🔥 Importante: Siempre que sea posible, elige **JSONB** sobre JSON. La única razón para usar JSON sería si necesitas preservar un formato de espaciado o un orden de claves específico de JSON, lo cual es raro en la práctica.
CaracterísticaJSONJSONB
---------
AlmacenamientoTexto plano, copia exacta del inputFormato binario descomprimido
Rendimiento LecturaLento (parseo en cada lectura)Rápido (pre-parseado)
---------
Rendimiento EscrituraRápido (guardar texto)Lento (parseo y conversión a binario)
Espacio DiscoMayor (espacios, duplicados)Menor (compacto)
---------
IndexaciónNo directo, requiere expresionesSí, directo para elementos internos
Orden ClavesPreservadoNo garantizado
---------
Claves DuplicadasPermite (última prevalece)Elimina (última prevalece)

Creando una Tabla con JSONB 📊

Consideremos una tabla para eventos, donde cada evento puede tener metadatos muy variados.

CREATE TABLE eventos (
    id SERIAL PRIMARY KEY,
    tipo VARCHAR(50) NOT NULL,
    fecha_evento TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    detalles JSONB
);

Insertando Datos en JSONB ➕

Puedes insertar objetos JSON completos como cadenas de texto.

INSERT INTO eventos (tipo, detalles) VALUES
('login', '{"usuario_id": 101, "ip_origen": "192.168.1.1", "exito": true}'),
('compra', '{"usuario_id": 205, "producto_id": 5001, "cantidad": 2, "total": 49.98, "metodo_pago": "tarjeta"}'),
('error_api', '{"codigo": 500, "mensaje": "Internal Server Error", "endpoint": "/api/v1/data", "parametros": {"user_id": 101, "action": "fetch"}}'),
('login', '{"usuario_id": 102, "ip_origen": "10.0.0.5", "exito": false, "razon": "password incorrecto"}');

Consultando Datos JSONB 🧐

JSONB ofrece un conjunto aún más potente de operadores y funciones que HSTORE para trabajar con datos anidados y complejos.

Accediendo a Valores

  • ->: Obtiene un campo JSON de un objeto JSONB o un elemento de un array JSONB (como texto JSON).
  • ->>: Obtiene un campo JSON de un objeto JSONB o un elemento de un array JSONB (como texto).
-- Obtener el usuario_id de eventos de login como texto JSON
SELECT tipo, detalles->'usuario_id'
FROM eventos
WHERE tipo = 'login';
-- Resultado: |tipo |?column?|
--            |-----|--------|
--            |login|"101"   |
--            |login|"102"   |

-- Obtener el usuario_id de eventos de login como texto
SELECT tipo, detalles->>'usuario_id'
FROM eventos
WHERE tipo = 'login';
-- Resultado: |tipo |usuario_id|
--            |-----|----------|
--            |login|101       |
--            |login|102       |

-- Acceder a un campo anidado
SELECT tipo, detalles->'parametros'->>'user_id'
FROM eventos
WHERE tipo = 'error_api';
-- Resultado: |tipo       |user_id|
--            |-----------|-------|
--            |error_api  |101    |

Buscando Contenido y Existencia

  • ?: ¿Existe la clave en el objeto JSONB?
  • ?|: ¿Existe al menos una de las claves en el objeto JSONB?
  • ?&: ¿Existen todas las claves en el objeto JSONB?
  • @>: ¿El JSONB de la izquierda contiene la estructura JSONB de la derecha?
  • <@: ¿El JSONB de la derecha contiene la estructura JSONB de la izquierda?
  • @@: Operador de coincidencia de ruta SQL/JSON (PostgreSQL 12+).
-- Eventos de compra con 'metodo_pago'
SELECT id, tipo, detalles
FROM eventos
WHERE detalles ? 'metodo_pago';

-- Eventos con 'ip_origen' Y 'exito' en sus detalles
SELECT id, tipo, detalles
FROM eventos
WHERE detalles ?& ARRAY['ip_origen', 'exito'];

-- Buscar eventos de login donde 'exito' es true
SELECT id, tipo, detalles
FROM eventos
WHERE detalles @> '{"exito": true}';

-- Buscar errores de API donde el código es 500
SELECT id, tipo, detalles
FROM eventos
WHERE detalles @> '{"codigo": 500}';

Funciones JSONB Utilitarias

PostgreSQL proporciona una variedad de funciones para manipular y consultar JSONB.

  • jsonb_object_keys(jsonb): Devuelve las claves de nivel superior de un objeto JSONB como un conjunto de texto.
  • jsonb_each(jsonb): Expande el objeto JSONB de nivel superior en un conjunto de pares clave-valor (texto, jsonb).
  • jsonb_each_text(jsonb): Como jsonb_each, pero devuelve ambos como texto.
  • jsonb_array_elements(jsonb): Expande un array JSONB a un conjunto de elementos JSONB.
-- Obtener todas las claves de los detalles de un evento de compra
SELECT DISTINCT jsonb_object_keys(detalles)
FROM eventos
WHERE tipo = 'compra';
-- Resultado: |jsonb_object_keys|
--            |-----------------|
--            |usuario_id       |
--            |producto_id      |
--            |cantidad         |
--            |total            |
--            |metodo_pago      |

-- Expandir los detalles de un evento para ver clave y valor por separado
SELECT id, (jsonb_each(detalles)).* 
FROM eventos
WHERE id = 2;
-- Resultado: |id|key        |value       |
--            |--|-----------|------------|
--            |2 |usuario_id |205         |
--            |2 |producto_id|5001        |
--            |2 |cantidad   |2           |
--            |2 |total      |49.98       |
--            |2 |metodo_pago|"tarjeta"   |

Actualizando Datos JSONB 📝

Actualizar datos en JSONB puede ser un poco más complejo, ya que necesitas construir el nuevo objeto JSONB.

Añadiendo o Modificando Campos

Usa el operador || para concatenar objetos JSONB. Si una clave existe en ambos, el valor del segundo objeto prevalece.

-- Añadir un campo 'devuelto' a un evento de compra
UPDATE eventos
SET detalles = detalles || '{"devuelto": false}'
WHERE id = 2;

SELECT detalles FROM eventos WHERE id = 2;
-- Resultado: {"total": 49.98, "cantidad": 2, "devuelto": false, "usuario_id": 205, "producto_id": 5001, "metodo_pago": "tarjeta"}

-- Modificar un campo existente (por ejemplo, cambiar la IP de un login)
UPDATE eventos
SET detalles = detalles || '{"ip_origen": "203.0.113.45"}'
WHERE id = 1;

SELECT detalles FROM eventos WHERE id = 1;
-- Resultado: {"exito": true, "usuario_id": 101, "ip_origen": "203.0.113.45"}

Eliminando Campos

Usa el operador - para eliminar una clave específica o un array de claves.

-- Eliminar el campo 'razon' de un evento de login
UPDATE eventos
SET detalles = detalles - 'razon'
WHERE id = 4;

SELECT detalles FROM eventos WHERE id = 4;
-- Resultado: {"exito": false, "usuario_id": 102, "ip_origen": "10.0.0.5"}

-- Eliminar múltiples campos
UPDATE eventos
SET detalles = detalles - ARRAY['endpoint', 'parametros']
WHERE id = 3;

SELECT detalles FROM eventos WHERE id = 3;
-- Resultado: {"codigo": 500, "mensaje": "Internal Server Error"}

Modificar Campos Anidados

Para modificar campos anidados, puedes usar la función jsonb_set.

jsonb_set(target jsonb, path text[], new_value jsonb, [create_if_missing boolean])

  • target: El JSONB original.
  • path: Un array de texto que indica la ruta al campo anidado.
  • new_value: El nuevo valor JSONB a establecer.
  • create_if_missing: Si es true (por defecto), crea la ruta si no existe. Si es false, solo actualiza si la ruta ya existe.
-- Cambiar el 'action' dentro de 'parametros' para el evento de error_api (id=3)
-- Primero, restablecer el evento 3 para el ejemplo
UPDATE eventos
SET detalles = '{"codigo": 500, "mensaje": "Internal Server Error", "endpoint": "/api/v1/data", "parametros": {"user_id": 101, "action": "fetch"}}'
WHERE id = 3;

UPDATE eventos
SET detalles = jsonb_set(detalles, '{parametros, action}', '"update"', false)
WHERE id = 3;

SELECT detalles FROM eventos WHERE id = 3;
-- Resultado: {"codigo": 500, "endpoint": "/api/v1/data", "mensaje": "Internal Server Error", "parametros": {"action": "update", "user_id": 101}}

Indexación de Datos JSONB para Rendimiento ✨

Para que las consultas sobre JSONB sean eficientes, especialmente en tablas grandes, es crucial crear índices.

Índices GIN (Generalized Inverted Index)

Los índices GIN son la opción más común y potente para JSONB. Permiten buscar rápidamente dentro del contenido de JSONB.

  • jsonb_ops: Índice GIN general para todo el valor JSONB. Soporta ?, ?|, ?&, @>, <@.
CREATE INDEX idx_eventos_detalles_gin ON eventos USING GIN (detalles jsonb_ops);
  • jsonb_path_ops: Un índice GIN más optimizado para el operador @> (contención). Es más pequeño y rápido, pero solo soporta @>.
CREATE INDEX idx_eventos_detalles_path_gin ON eventos USING GIN (detalles jsonb_path_ops);

Índices en Expresiones

Si necesitas buscar frecuentemente por un campo específico dentro de tu JSONB, puedes crear un índice en una expresión.

-- Índice para el 'usuario_id' dentro de los detalles (convertido a INT para búsqueda numérica)
CREATE INDEX idx_eventos_usuario_id ON eventos ((detalles->>'usuario_id')::int);

-- Índice para el campo 'exito' (convertido a BOOLEAN)
CREATE INDEX idx_eventos_exito ON eventos ((detalles->>'exito')::boolean);
💡 Consejo: Usa `EXPLAIN ANALYZE` para ver si tus índices están siendo utilizados correctamente y para identificar cuellos de botella en tus consultas.

Casos de Uso y Consideraciones Avanzadas 🎯

Cuándo Usar HSTORE vs. JSONB

CaracterísticaHSTOREJSONB
---------
ComplejidadDatos clave-valor simples (todo es texto).Documentos JSON complejos, anidados, arrays.
Tipos DatosSolo texto para claves y valores.Soporta tipos JSON nativos (números, booleanos, null, objetos, arrays, strings).
---------
RendimientoMuy rápido para operaciones clave-valor básicas.Más rápido que JSON para consultas, pero un poco más lento en inserción que HSTORE.
IndexaciónGIN, B-tree en claves/valores específicos.GIN para contención y búsqueda de texto, B-tree en expresiones.
---------
Uso TípicoMetadatos simples, etiquetas, preferencias de usuario.Logs de eventos, perfiles de usuario flexibles, configuraciones complejas, datos de productos con atributos variables.

Combinando Datos Relacionales y No Relacionales

Uno de los mayores beneficios de usar JSONB/HSTORE en PostgreSQL es la capacidad de combinar consultas relacionales con la flexibilidad de datos no relacionales.

Imagina que tienes una tabla de usuarios y quieres almacenar sus preferencias de forma dinámica en un campo JSONB.

CREATE TABLE usuarios (
    id SERIAL PRIMARY KEY,
    nombre VARCHAR(255) NOT NULL,
    email VARCHAR(255) UNIQUE NOT NULL,
    preferencias JSONB DEFAULT '{}'
);

INSERT INTO usuarios (nombre, email, preferencias) VALUES
('Alice', 'alice@example.com', '{"tema": "oscuro", "notificaciones": {"email": true, "sms": false}}'),
('Bob', 'bob@example.com', '{"tema": "claro", "idioma": "en", "zona_horaria": "America/New_York"}');

-- Consulta usuarios con tema oscuro
SELECT nombre, email
FROM usuarios
WHERE preferencias @> '{"tema": "oscuro"}';

-- Usuarios con notificaciones por email activas (anidado)
SELECT nombre, email
FROM usuarios
WHERE preferencias->'notificaciones'->>'email' = 'true';
productos id INT PK nombre VARCHAR precio DECIMAL atributos JSONB Detalles variables Data Flexible { "color": "rojo", "talla": "XL", "material": "algodón" } Esquema flexible NoSQL en SQL

Transformación y Normalización de Datos

A veces, los datos que recibes están en formato JSON o HSTORE, pero necesitas normalizarlos para análisis o reportes. PostgreSQL facilita esto.

-- Convertir HSTORE a una tabla de filas y columnas
SELECT id, nombre, (each(atributos)).*
FROM productos_hstore;
-- Resultado: |id|nombre           |key               |value         |
--            |--|-----------------|------------------|--------------|
--            |1 |Laptop X1        |ssd               |512GB         |
--            |1 |Laptop X1        |ram               |32GB          |
--            |1 |Laptop X1        |peso              |1.8kg         |
--            |2 |Smartphone S20   |color             |negro         |
--            |...               |...               |...           |

-- Convertir JSONB a una tabla, extrayendo campos específicos
SELECT
    id,
    tipo,
    detalles->>'usuario_id' AS usuario_id,
    detalles->>'total' AS total_compra
FROM eventos
WHERE tipo = 'compra';
-- Resultado: |id|tipo  |usuario_id|total_compra|
--            |--|------|----------|------------|
--            |2 |compra|205       |49.98       |

Consideraciones de Almacenamiento y Backup

Al usar tipos de datos no relacionales, el tamaño de tus datos puede crecer rápidamente. Asegúrate de:

  • Monitorear el uso del disco: Los campos JSONB pueden ocupar bastante espacio, especialmente si contienen datos complejos o muchos arrays.
  • Estrategias de Backup: Los backups estándar de PostgreSQL (pg_dump) manejan JSONB y HSTORE sin problemas, pero ten en cuenta el tamaño de los archivos resultantes.
  • Compresión: Considera la compresión a nivel de sistema de archivos o LVM si el tamaño es una preocupación crítica, aunque PostgreSQL ya optimiza JSONB internamente.
Eficiencia JSONB

Seguridad en Datos No Relacionales

Al igual que con cualquier dato en tu base de datos, aplica buenas prácticas de seguridad:

  • Validación de Entrada: Siempre valida los datos JSON/HSTORE que ingresan a tu base de datos desde aplicaciones externas para prevenir inyecciones o datos mal formados.
  • Permisos: Asegúrate de que los usuarios y roles de la base de datos tengan los permisos adecuados para SELECT, INSERT, UPDATE en las columnas que contienen JSONB/HSTORE.

Conclusión y Próximos Pasos 🚀

PostgreSQL con JSONB y HSTORE te brinda una flexibilidad increíble, permitiéndote manejar datos no relacionales sin abandonar las ventajas de una base de datos relacional robusta. Has aprendido a:

  • Utilizar HSTORE para almacenar pares clave-valor simples.
  • Dominar JSONB para estructuras JSON más complejas y anidadas.
  • Realizar inserciones, consultas y actualizaciones eficientes en ambos tipos.
  • Optimizar el rendimiento con la indexación GIN y en expresiones.
  • Integrar datos relacionales y no relacionales en tus consultas.

Esta capacidad híbrida es fundamental para el desarrollo moderno, donde la agilidad y la adaptabilidad son clave. Experimenta con diferentes estructuras de datos y operadores para encontrar la mejor manera de aplicar estos conceptos a tus propios proyectos.

FAQs sobre JSONB y HSTORE

1. ¿Puedo validar el esquema de JSONB?

Sí, a partir de PostgreSQL 13, puedes usar la función jsonb_path_match con expresiones JSONPath para validar el esquema. También existen extensiones de terceros como pg_json_schema.

2. ¿Es posible hacer JOINs en campos JSONB o HSTORE?

Directamente no se JOINea en el campo JSONB/HSTORE, pero puedes extraer un valor de estos campos y luego hacer un JOIN con ese valor. Por ejemplo, JOIN otra_tabla ON otra_tabla.id = (eventos.detalles->>'usuario_id')::int;

3. ¿Cómo migro datos de HSTORE a JSONB (o viceversa)?

Puedes usar las funciones de conversión integradas. Por ejemplo, para convertir HSTORE a JSONB:

SELECT to_jsonb(atributos) FROM productos_hstore;

Para convertir JSONB a HSTORE, asegúrate de que el JSONB solo contenga objetos de nivel superior con valores de cadena:

SELECT hstore(jsonb_each_text(detalles)) FROM eventos WHERE ...;

4. ¿Qué pasa con el rendimiento si mis campos JSONB son muy grandes?

Los campos JSONB muy grandes pueden afectar el rendimiento, ya que más datos deben ser leídos del disco y procesados. Considera normalizar partes de datos si son muy grandes y se acceden frecuentemente de forma independiente, o si necesitas indexar profundamente un campo muy anidado con alta cardinalidad.

Tutoriales relacionados

Comentarios (0)

Aún no hay comentarios. ¡Sé el primero!