JSONB
In PostgreSQL 9.4 new JSONB type was introduced. It allows storing of JSON data and enforces that they are represented in valid JSON format. It allows to query individual items in the JSON object and provides various JSON related functions.
Implementing Custom Mapping
Overview
To implement Hibernate mapping for a JSONB type you need to do the following:
- Create a custom Hibernate Dialect and define that JSONB DB type will be represented as a Java object.
- Register your custom dialect in Spring Boot's application properties.
- Create a custom class that implements a UserType interface. This will contain all the logic of converting from and to JSON.
- Register your new UserType class using
@TypeDef
annotation. This will link your class to the type name from the DB.
Creating custom Hibernate Dialect
Hibernate's PostgreSQL94Dialect
does not support JSONB Type. Because of that, you need to create your own custom dialect. There you need to register a new column type for JSONB. The good news is that you can directly extend PostgreSQL94Dialect
and add just the column registration you need on top of the existing functionality.
import java.sql.Types;
public class CustomPostgreSqlDialect extends PostgreSQL94Dialect {
public CustomPostgreSqlDialect() {
this.registerColumnType(Types.JAVA_OBJECT, "jsonb");
}
}
Registering Custom Dialect in Spring Boot app
This step is rather simple. All you need to do is to register your newly created Hibernate Dialect in application.properties
of your Spring Boot app. Just add the following line (be sure to change package name depending on where your class is located):
spring.jpa.properties.hibernate.dialect=com.vojtechruzicka.CustomPostgreSqlDialect
Creating custom UserType for JSONB
To convert from and to JSONB, you need to implement org.hibernate.usertype.UserType
interface, which provides various methods for custom type mapping.
- int[] sqlTypes() - This provides mapping to
java.sql.Types
, you should provideTypes.JAVA_OBJECT
here. - Class returnedClass() - Java Class which is the result of mapping from JSOB, return type of
nullSafeGet()
. - Object nullSafeGet(ResultSet rs, String[] names, SessionImplementor session, Object owner) - Create a new instance of the mapped class from JDBC ResultSet. JSON/Object mapping library required.
- void nullSafeSet(PreparedStatement st, Object value, int index, SessionImplementor session) - Write an instance of the mapped class to a Prepared Statement. JSON/Object mapping library required.
- boolean isMutable() - This determines whether mapped classes are mutable or not.
- boolean equals(Object x, Object y), int hashCode(Object x) - Nothing special here.
- Serializable disassemble(Object value), Object assemble(Serializable cached, Object owner) - These methods are for converting object from/to its cacheable representation
- Object replace(Object original, Object target, Object owner) - Is called during merging from a detached entity. For mutable object and immutable object without composite component, it is safe to return a copy of the original object.
- Object deepCopy(Object value) - Returns a deep copy of provided object. You can, for example, use Apache
SerializationUtils.clone()
to make deep copy using serialization/deserialization.
The two primary methods for converting from/to JSON are nullSafeGet
and nullSafeSet
. You will need some library for transforming a JSON string to an object representation and vice versa. You can use for example GSON or Jackson.
Then you need to decide to which class you will map to your JSON. This depends on the structure of JSON which is stored in your database. In my application, I was sure my JSON is just a flat map of key-value pairs, so I decided to use a java Map for the mapping for simplicity. Be aware that this may not always be the case as JSON can contain arrays and nested elements. In that case, an ordinary map could not be used.
Example of implementation of UserType can be something like this:
package com.vojtechruzicka.hibernate;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import org.hibernate.HibernateException;
import org.hibernate.engine.spi.SessionImplementor;
import org.hibernate.type.SerializationException;
import org.hibernate.usertype.UserType;
import org.postgresql.util.PGobject;
import org.springframework.util.ObjectUtils;
import java.io.Serializable;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Types;
import java.util.HashMap;
import java.util.Map;
public class JsonDataUserType implements UserType {
private final Gson gson = new GsonBuilder().serializeNulls().create();
@Override
public void nullSafeSet(PreparedStatement st, Object value, int index, SessionImplementor session) throws HibernateException, SQLException {
if (value == null) {
st.setNull(index, Types.OTHER);
} else {
st.setObject(index, gson.toJson(value, Map.class), Types.OTHER);
}
}
@Override
public Object deepCopy(Object originalValue) throws HibernateException {
if (originalValue == null) {
return null;
}
if (!(originalValue instanceof Map)) {
return null;
}
Map resultMap = new HashMap<>();
Map<?, ?> tempMap = (Map<?, ?>) originalValue;
tempMap.forEach((key, value) -> resultMap.put((String) key, (String) value));
return resultMap;
}
@Override
public Object nullSafeGet(ResultSet rs, String[] names, SessionImplementor session, Object owner) throws HibernateException, SQLException {
PGobject o = (PGobject) rs.getObject(names[0]);
if (o.getValue() != null) {
return gson.fromJson(o.getValue(), Map.class);
}
return new HashMap();
}
@Override
public Serializable disassemble(Object value) throws HibernateException {
Object copy = deepCopy(value);
if (copy instanceof Serializable) {
return (Serializable) copy;
}
throw new SerializationException(String.format("Cannot serialize '%s', %s is not Serializable.", value, value.getClass()), null);
}
@Override
public Object assemble(Serializable cached, Object owner) throws HibernateException {
return deepCopy(cached);
}
@Override
public Object replace(Object original, Object target, Object owner) throws HibernateException {
return deepCopy(original);
}
@Override
public boolean isMutable() {
return true;
}
@Override
public int hashCode(Object x) throws HibernateException {
if (x == null) {
return 0;
}
return x.hashCode();
}
@Override
public boolean equals(Object x, Object y) throws HibernateException {
return ObjectUtils.nullSafeEquals(x, y);
}
@Override
public Class<?> returnedClass() {
return Map.class;
}
@Override
public int[] sqlTypes() {
return new int[]{Types.JAVA_OBJECT};
}
}
Registering UserType
Once your UserType is created, you need to register it as a Type Definition using @TypeDef
annotation.
You can annotate your Domain Class directly:
@Entity
@Table(name = "person")
@TypeDef(name = "JsonDataUserType", typeClass = JsonDataUserType.class)
public class Person {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
private String firstName;
private String lastName;
@Type(type = "JsonDataUserType")
private Map additionalData;
...
}
Alternatively, you can create package-info.java
file and annotate the package directly. In the annotation, you provide type name and a Java class for which it should be the default mapping.
@TypeDef(name = "JsonDataUserType", typeClass = JsonDataUserType.class)
package com.vojtechruzicka.hibernate;
import org.hibernate.annotations.TypeDef;
Source Code
You can check an example of the full Spring Boot Application with implemented JOSNB mapping.
Disadvantages
While PostgreSQL JSONB type provides flexibility, it should be used just when appropriate. The only check being performed is that stored data is actually in a valid JSON format. You cannot impose any other constraints as with regular columns - such as not null or enforce a particular Data Type (Integer, VarChar, Date). Therefore, it is best suited for providing an additional optional set of data to an entity, where you cannot be sure before which data is would contain. And such data would differ a lot among each of the rows. Such example can be a user-provided set of additional data. You should always carefully consider which data is better suited as regular columns and which should be stored as JSON.
Furthermore, keep in mind that JSONB type is PostgreSQL specific and will not be available in other DBs. This may be a problem when you decide to later migrate to other RDS. Also, you will not be able to use in-memory DB in your integration tests at none of them supported by Spring currently supports the JSONB type. It is worth considering that using JSON types will have direct impact on performance - for more details see When to avoid JSONB in PostgreSQL schema.