Este es otro artículo de la serie relacionada con el apoyo a las funciones JSON de Postgres en un proyecto que utiliza elmarco de Hibernate con la versión 6. El tema para el artículo son las operaciones de modificación en registros JSON. Como en el artículo anterior, conviene mencionar que Postgres podría no tener operaciones tan comprensivas como otras bases de datos NoSQL comoMongoDB para la modificación de JSON (aunque, con las construcciones de función apropiadas, es posible alcanzar el mismo efecto). Aún así, es apropiado para la mayoría de los proyectos que requieren modificación de JSON. Además, con el soporte de transacciones (no soportadas en una base de datos NoSQL a ese nivel), es una buena idea utilizar Postgres con datos JSON. Naturalmente, las bases de datos NoSQL tienen otras ventajas que podrían ser mejores para los proyectos.
Generalmente hay muchos artículos sobre el apoyo deJSON en Postgres. Este artículo se centra en integrar este apoyo con la biblioteca Hibernate 6.
En caso de que alguien esté interesado en consultar datos JSON o búsquedas de texto utilizando Postgres y Hibernate, por favor vea los enlaces siguientes:
- Búsqueda de Texto Completo de Postgres con Hibernate 6
- Funciones de JSON de Postgres con Hibernate 6
Datos de prueba
Para el artículo, supongamos que nuestra base de datos tiene una tabla llamada item
, que tiene una columna con contenido JSON, como en el ejemplo de abajo:
create table item (
id int8 not null,
jsonb_content jsonb,
primary key (id)
)
También podríamos tener algunos datos de prueba:
INSERT INTO item (id, jsonb_content) VALUES (1, '{"top_element_with_set_of_values":["TAG1","TAG2","TAG11","TAG12","TAG21","TAG22"]}');
INSERT INTO item (id, jsonb_content) VALUES (2, '{"top_element_with_set_of_values":["TAG3"]}');
-- item without any properties, just an empty json
INSERT INTO item (id, jsonb_content) VALUES (6, '{}');
-- int values
INSERT INTO item (id, jsonb_content) VALUES (7, '{"integer_value": 132}');
-- double values
INSERT INTO item (id, jsonb_content) VALUES (10, '{"double_value": 353.01}');
INSERT INTO item (id, jsonb_content) VALUES (11, '{"double_value": -1137.98}');
-- enum values
INSERT INTO item (id, jsonb_content) VALUES (13, '{"enum_value": "SUPER"}');
-- string values
INSERT INTO item (id, jsonb_content) VALUES (18, '{"string_value": "the end of records"}');
Ejecución SQL nativa
Como en otros frameworks de Java, con Hibernate, puede ejecutar consultas SQL nativas, lo cual está bien documentado y hay muchos ejemplos en internet. Es por eso que en este artículo no nos centraremos en la ejecución de operaciones de SQL nativa. Sin embargo, habrá ejemplos de qué clase de SQL generan las operaciones del API JPA. Debido a que Hibernate es una implementación de JPA, tiene sentido mostrar cómo la API de JPA puede modificar datos JSON en la base de datos de Postgres.
Modificar propiedades del objeto JSON y no el objeto JSON completo (ruta)
Establecer el payload JSON completo para una columna es fácil y no requiere mucha explicación. Simplemente establecemos el valor de la propiedad en nuestra clase Entity
, que representa una columna con contenido JSON.
Es similar a establecer una o varias propiedades de JSON para una sola fila de base de datos. Simplemente leemos la fila de la tabla, deserializamos el valor JSON a un POJO que representa un objeto JSON, establecemos valores para propiedades particulares y actualizamos los registros de base de datos con el payload completo. Sin embargo, such an approach might not be practical when we want to modify JSON properties for multiple database rows.
Supongamos que tenemos que realizar actualizaciones en lote de propiedades JSON particulares. Extraer datos de la base de datos y actualizar cada registro podría no ser un método efectivo.
Sería mucho mejor realizar una actualización de esa manera con una solaupdate
instrucción en la cual establezcamos valores para las propiedades JSON particulares. Afortunadamente, Postgres posee funciones que modifican el contenido JSON y pueden ser utilizadas en la instrucción SQL de actualización.
Posjsonhelper
Hibernate ofrece un mejor soporte para la modificación de JSON en la versión 7, incluyendo la mayoría de las funciones y operadores mencionados en este artículo. Sin embargo, no hay planes para agregar tal soporte en la versión 6. Afortunadamente, el proyecto Posjsonhelperagrega talesfunciones de soportepara Hibernate en la versión 6. Todos los ejemplos que se muestran a continuación utilizarán la biblioteca Posjsonhelper.Revise esteenlacepara averiguar cómo adjuntar una biblioteca a su proyecto Java. También tendrá que adjuntar FunctionContributor.
Todos los ejemplos utilizan la clase de entidad Java que representa la tabla item
, cuya definición se mencionó anteriormente:
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.annotations.Type;
import org.hibernate.type.SqlTypes;
import java.io.Serializable;
name = "item") (
public class Item implements Serializable {
private Long id;
SqlTypes.JSON) (
name = "jsonb_content") (
private JsonbContent jsonbContent;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public JsonbContent getJsonbContent() {
return jsonbContent;
}
public void setJsonbContent(JsonbContent jsonbContent) {
this.jsonbContent = jsonbContent;
}
}
jsonb_set Function Wrapper
La función jsonb_set
es probablemente la función más útil cuando se requiere modificar datos JSON. Permite establecer propiedades específicas para objetos JSON y elementos de array específicos en función del índice del array.
Por ejemplo, el siguiente código añade la propiedad "cumpleaños"
a la propiedad interna "niño"
.
// GIVEN
Long itemId = 19L;
String property = "birthday";
String value = "1970-01-01";
String expectedJson = "{\"child\": {\"pets\" : [\"dog\"], \"birthday\": \"1970-01-01\"}}";
// when
CriteriaUpdate<Item> criteriaUpdate = entityManager.getCriteriaBuilder().createCriteriaUpdate(Item.class);
Root<Item> root = criteriaUpdate.from(Item.class);
// Set the property you want to update and the new value
criteriaUpdate.set("jsonbContent", new JsonbSetFunction((NodeBuilder) entityManager.getCriteriaBuilder(), root.get("jsonbContent"), new JsonTextArrayBuilder().append("child").append(property).build().toString(), JSONObject.quote(value), hibernateContext));
// Add any conditions to restrict which entities will be updated
criteriaUpdate.where(entityManager.getCriteriaBuilder().equal(root.get("id"), itemId));
// Execute the update
entityManager.createQuery(criteriaUpdate).executeUpdate();
// then
Item item = tested.findById(itemId);
assertThat((String) JsonPath.read(item.getJsonbContent(), "$.child." + property)).isEqualTo(value);
JSONObject jsonObject = new JSONObject(expectedJson);
DocumentContext document = JsonPath.parse((Object) JsonPath.read(item.getJsonbContent(), "$"));
assertThat(document.jsonString()).isEqualTo(jsonObject.toString());
Este código generaría una sentencia SQL de este tipo:
update
item
set
jsonb_content=jsonb_set(jsonb_content, ?::text[], ?::jsonb)
where
id=?
Hibernate:
select
i1_0.id,
i1_0.jsonb_content
from
item i1_0
where
i1_0.id=?
Wrapper del operador de concatenación “||”
El wrapper del operador de concatenación (||) concatena dos valores JSONB en un nuevo valor JSONB.
Basado en la documentación de Postgres, el comportamiento del operador es el siguiente:
Concatenar dos arrays genera un array que contiene todos los elementos de cada entrada. Concatenar dos objetos genera un objeto que contiene la unión de sus claves, tomando el valor del segundo objeto cuando hay claves duplicadas. Todos los demás casos se tratan convirtiendo una entrada que no sea una matriz en una matriz de un solo elemento, y procediendo a continuación como para dos matrices. No opera de forma recursiva: sólo se fusiona la matriz o estructura de objetos de nivel superior.
Aquí tiene un ejemplo de cómo utilizar esta envoltura en su código:
// GIVEN
Long itemId = 19l;
String property = "birthday";
String value = "1970-01-01";
// WHEN
CriteriaUpdate<Item> criteriaUpdate = entityManager.getCriteriaBuilder().createCriteriaUpdate(Item.class);
Root<Item> root = criteriaUpdate.from(Item.class);
JSONObject jsonObject = new JSONObject();
jsonObject.put("child", new JSONObject());
jsonObject.getJSONObject("child").put(property, value);
criteriaUpdate.set("jsonbContent", new ConcatenateJsonbOperator((NodeBuilder) entityManager.getCriteriaBuilder(), root.get("jsonbContent"), jsonObject.toString(), hibernateContext));
criteriaUpdate.where(entityManager.getCriteriaBuilder().equal(root.get("id"), itemId));
entityManager.createQuery(criteriaUpdate).executeUpdate();
// THEN
Item item = tested.findById(itemId);
assertThat((String) JsonPath.read(item.getJsonbContent(), "$.child." + property)).isEqualTo(value);
JSONObject expectedJsonObject = new JSONObject().put(property, value);
DocumentContext document = JsonPath.parse((Object) JsonPath.read(item.getJsonbContent(), "$.child"));
assertThat(document.jsonString()).isEqualTo(expectedJsonObject.toString());
Code fusionar un objeto JSON con la propiedad child
con el objeto JSON ya almacenado en la base de datos.
Este código genera una consulta SQL de este tipo:
update
item
set
jsonb_content=jsonb_content || ?::jsonb
where
id=?
Hibernate:
select
i1_0.id,
i1_0.jsonb_content
from
item i1_0
where
i1_0.id=?
Eliminar el campo o elemento del arreglo en función del índice especificado en la ruta “#-“
La Posjsonhelper tiene una capa de abstracción para la operación de eliminación (#-
). Elimina el campo o elemento del arreglo en función del índice especificado en la ruta, donde los elementos de la ruta pueden ser claves de campos o índices de arreglos. Por ejemplo, el código de abajo elimina de la propiedad del objeto JSON en función de la ruta JSON "child.pets"
.
// GIVEN
Item item = tested.findById(19L);
JSONObject jsonObject = new JSONObject("{\"child\": {\"pets\" : [\"dog\"]}}");
DocumentContext document = JsonPath.parse((Object) JsonPath.read(item.getJsonbContent(), "$"));
assertThat(document.jsonString()).isEqualTo(jsonObject.toString());
// WHEN
CriteriaUpdate<Item> criteriaUpdate = entityManager.getCriteriaBuilder().createCriteriaUpdate(Item.class);
Root<Item> root = criteriaUpdate.from(Item.class);
// Set the property you want to update and the new value
criteriaUpdate.set("jsonbContent", new DeleteJsonbBySpecifiedPathOperator((NodeBuilder) entityManager.getCriteriaBuilder(), root.get("jsonbContent"), new JsonTextArrayBuilder().append("child").append("pets").build().toString(), hibernateContext));
// Add any conditions to restrict which entities will be updated
criteriaUpdate.where(entityManager.getCriteriaBuilder().equal(root.get("id"), 19L));
// Execute the update
entityManager.createQuery(criteriaUpdate).executeUpdate();
// THEN
entityManager.refresh(item);
jsonObject = new JSONObject("{\"child\": {}}");
document = JsonPath.parse((Object) JsonPath.read(item.getJsonbContent(), "$"));
assertThat(document.jsonString()).isEqualTo(jsonObject.toString());
El SQL generado sería:
update
item
set
jsonb_content=(jsonb_content #- ?::text[])
where
id=?
Eliminar múltiples elementos de arreglo en la ruta especificada
Por defecto, Postgres (al menos en la versión 16) no tiene una función integrada que permita la eliminación de elementos de arreglo en función de su valor. Sin embargo, tiene el operador integrado, -#
, que mencionamos anteriormente, que ayuda a eliminar elementos de arreglo en función del índice pero no en función de su valor.
Para este propósito, la Posjsonhelper puede generar una función que debe agregarse a la operación DDL y ejecutarse en su base de datos.
CREATE OR REPLACE FUNCTION {{schema}}.remove_values_from_json_array(input_json jsonb, values_to_remove jsonb) RETURNS jsonb AS $$
DECLARE
result jsonb;
BEGIN
IF jsonb_typeof(values_to_remove) <> 'array' THEN
RAISE EXCEPTION 'values_to_remove must be a JSON array';
END IF;
result := (
SELECT jsonb_agg(element)
FROM jsonb_array_elements(input_json) AS element
WHERE NOT (element IN (SELECT jsonb_array_elements(values_to_remove)))
);
RETURN COALESCE(result, '[]'::jsonb);
END;
$$ LANGUAGE plpgsql;
Uno de los wrappers utilizará esta función para permitir la eliminación de múltiples valores del arreglo JSON. Este código elimina elementos "mask"
y "compass"
para la propiedad "child.inventory"
.
// GIVEN
Item item = tested.findById(24L);
DocumentContext document = JsonPath.parse((Object) JsonPath.read(item.getJsonbContent(), "$"));
assertThat(document.jsonString()).isEqualTo("{\"child\":{\"pets\":[\"crab\",\"chameleon\"]},\"inventory\":[\"mask\",\"fins\",\"compass\"]}");
CriteriaUpdate<Item> criteriaUpdate = entityManager.getCriteriaBuilder().createCriteriaUpdate(Item.class);
Root<Item> root = criteriaUpdate.from(Item.class);
NodeBuilder nodeBuilder = (NodeBuilder) entityManager.getCriteriaBuilder();
JSONArray toRemoveJSONArray = new JSONArray(Arrays.asList("mask", "compass"));
RemoveJsonValuesFromJsonArrayFunction deleteOperator = new RemoveJsonValuesFromJsonArrayFunction(nodeBuilder, new JsonBExtractPath(root.get("jsonbContent"), nodeBuilder, Arrays.asList("inventory")), toRemoveJSONArray.toString(), hibernateContext);
JsonbSetFunction jsonbSetFunction = new JsonbSetFunction(nodeBuilder, (SqmTypedNode) root.get("jsonbContent"), new JsonTextArrayBuilder().append("inventory").build().toString(), deleteOperator, hibernateContext);
// Set the property you want to update and the new value
criteriaUpdate.set("jsonbContent", jsonbSetFunction);
// Add any conditions to restrict which entities will be updated
criteriaUpdate.where(entityManager.getCriteriaBuilder().equal(root.get("id"), 24L));
// WHEN
entityManager.createQuery(criteriaUpdate).executeUpdate();
// THEN
entityManager.refresh(item);
document = JsonPath.parse((Object) JsonPath.read(item.getJsonbContent(), "$"));
assertThat(document.jsonString()).isEqualTo("{\"child\":{\"pets\":[\"crab\",\"chameleon\"]},\"inventory\":[\"fins\"]}");
Aquí está el SQL generado por el código de arriba:
update
item
set
jsonb_content=jsonb_set(jsonb_content, ?::text[], remove_values_from_json_array(jsonb_extract_path(jsonb_content, ?), ?::jsonb))
where
id=?
Hibernate6JsonUpdateStatementBuilder: Cómo combinar múltiples operaciones de modificación en una sola sentencia de actualización.
Todos los ejemplos anteriores demostraron la ejecución de una sola operación que modifica los datos JSON. Naturalmente, podemos tener sentencias de actualización en nuestro código que usen muchos de los wrappers mencionados en este artículo juntos. Sin embargo, ser consciente de cómo se ejecutarán esas operaciones y funciones es crucial porque tiene sentido cuando el resultado de la primera operación JSON es una entrada para las operaciones de modificación de JSON siguientes. La salida de esa operación sería una entrada para la próxima operación, y así sucesivamente, hasta la última operación de modificación de JSON.
Para ilustrar esto mejor, revise el código SQL.
update
item
set
jsonb_content=
jsonb_set(
jsonb_set(
jsonb_set(
jsonb_set(
(
(jsonb_content #- ?::text[]) -- the most nested #- operator
#- ?::text[])
, ?::text[], ?::jsonb) -- the most nested jsonb_set operation
, ?::text[], ?::jsonb)
, ?::text[], ?::jsonb)
, ?::text[], ?::jsonb)
where
id=?
Esto asume que tenemos cuatro ejecuciones de la función jsonb_set
y dos operaciones delete
. La operación delete
más anidada es una primera operación de modificación de JSON porque se pasa como parámetro el valor original de una columna que almacena datos JSON.
Aunque esta es la approach correcta y el wrapper existente permite la creación de tal UPDATE
statement, puede que no sea legible desde el punto de vista del código. Afortunadamente, Posjsonhelper tiene un componente constructor que hace que la construcción de una declaración tan compleja sea fácil.
El tipo Hibernate6JsonUpdateStatementBuilder
permite la construcción de sentencias de actualización con múltiples operaciones que modifican JSON y dependen unas de otras.
A continuación se muestra un ejemplo de código:
// GIVEN
Item item = tested.findById(23L);
DocumentContext document = JsonPath.parse((Object) JsonPath.read(item.getJsonbContent(), "$"));
assertThat(document.jsonString()).isEqualTo("{\"child\":{\"pets\":[\"dog\"]},\"inventory\":[\"mask\",\"fins\"],\"nicknames\":{\"school\":\"bambo\",\"childhood\":\"bob\"}}");
CriteriaUpdate<Item> criteriaUpdate = entityManager.getCriteriaBuilder().createCriteriaUpdate(Item.class);
Root<Item> root = criteriaUpdate.from(Item.class);
Hibernate6JsonUpdateStatementBuilder hibernate6JsonUpdateStatementBuilder = new Hibernate6JsonUpdateStatementBuilder(root.get("jsonbContent"), (NodeBuilder) entityManager.getCriteriaBuilder(), hibernateContext);
hibernate6JsonUpdateStatementBuilder.appendJsonbSet(new JsonTextArrayBuilder().append("child").append("birthday").build(), quote("2021-11-23"));
hibernate6JsonUpdateStatementBuilder.appendJsonbSet(new JsonTextArrayBuilder().append("child").append("pets").build(), "[\"cat\"]");
hibernate6JsonUpdateStatementBuilder.appendDeleteBySpecificPath(new JsonTextArrayBuilder().append("inventory").append("0").build());
hibernate6JsonUpdateStatementBuilder.appendJsonbSet(new JsonTextArrayBuilder().append("parents").append(0).build(), "{\"type\":\"mom\", \"name\":\"simone\"}");
hibernate6JsonUpdateStatementBuilder.appendJsonbSet(new JsonTextArrayBuilder().append("parents").build(), "[]");
hibernate6JsonUpdateStatementBuilder.appendDeleteBySpecificPath(new JsonTextArrayBuilder().append("nicknames").append("childhood").build());
// Set the property you want to update and the new value
criteriaUpdate.set("jsonbContent", hibernate6JsonUpdateStatementBuilder.build());
// Add any conditions to restrict which entities will be updated
criteriaUpdate.where(entityManager.getCriteriaBuilder().equal(root.get("id"), 23L));
// WHEN
entityManager.createQuery(criteriaUpdate).executeUpdate();
// THEN
entityManager.refresh(item);
document = JsonPath.parse((Object) JsonPath.read(item.getJsonbContent(), "$"));
assertThat(document.jsonString()).isEqualTo("{\"child\":{\"pets\":[\"cat\"],\"birthday\":\"2021-11-23\"},\"parents\":[{\"name\":\"simone\",\"type\":\"mom\"}],\"inventory\":[\"fins\"],\"nicknames\":{\"school\":\"bambo\"}}");
La declaración SQL mencionada anteriormente fue generada por este código.
Para saber más sobre cómo funciona el constructor, por favor revise la documentación.
Conclusión
La base de datos Postgres ofrece un amplio abanico de posibilidades en cuanto a operaciones de modificación de datos JSON. Esto nos lleva a considerar a Postgres como una buena opción para almacenar documentos. Así que, si nuestra solución no requiere un mayor rendimiento de lectura, una mejor escalabilidad o sharding (aunque todas esas cosas se pueden lograr con la base de datos Postgres, especialmente con soluciones proporcionadas por proveedores de servicios en la nube como AWS), entonces es interesante considerar almacenar sus documentos JSON en una base de datos Postgres. No mencionar la respaldo de transacciones con bases de datos como Postgres.
Source:
https://dzone.com/articles/modify-json-data-in-postgres-and-hibernate