Skip to content

Add support for PostgreSQL's JSON[B] #1002

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
<mysql-connector-java.version>8.0.23</mysql-connector-java.version>
<postgresql.version>42.2.19</postgresql.version>
<oracle.version>19.6.0.0</oracle.version>
<jackson.databind.version>2.12.3</jackson.databind.version>

<!-- test utilities-->
<awaitility.version>4.0.3</awaitility.version>
Expand Down
21 changes: 14 additions & 7 deletions spring-data-jdbc/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,20 @@
<optional>true</optional>
</dependency>

<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.databind.version}</version>
<optional>true</optional>
</dependency>

<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>${postgresql.version}</version>
<optional>true</optional>
</dependency>

<dependency>
<groupId>org.hsqldb</groupId>
<artifactId>hsqldb</artifactId>
Expand Down Expand Up @@ -172,13 +186,6 @@
<scope>test</scope>
</dependency>

<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>${postgresql.version}</version>
<scope>test</scope>
</dependency>

<dependency>
<groupId>org.mariadb.jdbc</groupId>
<artifactId>mariadb-java-client</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package org.springframework.data.jdbc.core.convert;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.postgresql.util.PGobject;
import org.springframework.core.convert.converter.Converter;

/**
* An abstract class for building your own converter for PostgerSQL's JSON[b].
*
* @author Nikita Konev
*/
public abstract class AbstractPostgresJsonReadingConverter<T> implements Converter<PGobject, T> {
private final ObjectMapper objectMapper;
private final Class<T> valueType;

public AbstractPostgresJsonReadingConverter(ObjectMapper objectMapper, Class<T> valueType) {
this.objectMapper = objectMapper;
this.valueType = valueType;
}

@Override
public T convert(PGobject pgObject) {
try {
final String source = pgObject.getValue();
return objectMapper.readValue(source, valueType);
} catch (JsonProcessingException e) {
throw new RuntimeException("Unable to deserialize to json " + pgObject, e);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package org.springframework.data.jdbc.core.convert;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.postgresql.util.PGobject;
import org.springframework.core.convert.converter.Converter;
import java.sql.SQLException;

/**
* An abstract class for building your own converter for PostgerSQL's JSON[b].
*
* @author Nikita Konev
*/
public abstract class AbstractPostgresJsonWritingConverter<T> implements Converter<T, PGobject> {
private final ObjectMapper objectMapper;
private final boolean jsonb;

public AbstractPostgresJsonWritingConverter(ObjectMapper objectMapper, boolean jsonb) {
this.objectMapper = objectMapper;
this.jsonb = jsonb;
}

@Override
public PGobject convert(T source) {
try {
final PGobject pGobject = new PGobject();
pGobject.setType(jsonb ? "jsonb" : "json");
pGobject.setValue(objectMapper.writeValueAsString(source));
return pGobject;
} catch (JsonProcessingException | SQLException e) {
throw new RuntimeException("Unable to serialize to json " + source, e);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
package org.springframework.data.jdbc.core.convert;

import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.*;
import org.springframework.core.convert.converter.Converter;
import org.springframework.data.annotation.Id;
import org.springframework.data.convert.CustomConversions;
import org.springframework.data.convert.ReadingConverter;
import org.springframework.data.convert.WritingConverter;
import org.springframework.data.jdbc.core.mapping.JdbcSimpleTypes;
import org.springframework.data.jdbc.repository.config.EnableJdbcRepositories;
import org.springframework.data.jdbc.testing.TestConfiguration;
import org.springframework.data.mapping.model.SimpleTypeHolder;
import org.springframework.data.relational.core.dialect.Dialect;
import org.springframework.data.relational.core.mapping.Table;
import org.springframework.data.repository.CrudRepository;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.transaction.annotation.Transactional;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;

/**
* Test for PostgreSQL JSON[B] converters.
* Start this test with -Dspring.profiles.active=postgres
*
* @author Nikita Konev
*/
@EnabledIfSystemProperty(named = "spring.profiles.active", matches = "postgres")
@ContextConfiguration
@Transactional
@ExtendWith(SpringExtension.class)
public class PostgresJsonConvertersIntegrationTests {

@Profile("postgres")
@Configuration
@Import(TestConfiguration.class)
@EnableJdbcRepositories(considerNestedRepositories = true,
includeFilters = @ComponentScan.Filter(value = CustomerRepository.class, type = FilterType.ASSIGNABLE_TYPE))
static class Config {

private final ObjectMapper objectMapper = new ObjectMapper();

@Bean
Class<?> testClass() {
return PostgresJsonConvertersIntegrationTests.class;
}

@WritingConverter
static class PersonDataWritingConverter extends AbstractPostgresJsonWritingConverter<PersonData> {

public PersonDataWritingConverter(ObjectMapper objectMapper) {
super(objectMapper, true);
}
}

@ReadingConverter
static class PersonDataReadingConverter extends AbstractPostgresJsonReadingConverter<PersonData> {
public PersonDataReadingConverter(ObjectMapper objectMapper) {
super(objectMapper, PersonData.class);
}
}

@WritingConverter
static class SessionDataWritingConverter extends AbstractPostgresJsonWritingConverter<SessionData> {
public SessionDataWritingConverter(ObjectMapper objectMapper) {
super(objectMapper, true);
}
}

@ReadingConverter
static class SessionDataReadingConverter extends AbstractPostgresJsonReadingConverter<SessionData> {
public SessionDataReadingConverter(ObjectMapper objectMapper) {
super(objectMapper, SessionData.class);
}
}

private List<Object> storeConverters(Dialect dialect) {

List<Object> converters = new ArrayList<>();
converters.addAll(dialect.getConverters());
converters.addAll(JdbcCustomConversions.storeConverters());
return converters;
}

protected List<?> userConverters() {
final List<Converter> list = new ArrayList<>();
list.add(new PersonDataWritingConverter(objectMapper));
list.add(new PersonDataReadingConverter(objectMapper));
list.add(new SessionDataWritingConverter(objectMapper));
list.add(new SessionDataReadingConverter(objectMapper));
return list;
}

@Primary
@Bean
CustomConversions jdbcCustomConversions(Dialect dialect) {

SimpleTypeHolder simpleTypeHolder = dialect.simpleTypes().isEmpty() ? JdbcSimpleTypes.HOLDER
: new SimpleTypeHolder(dialect.simpleTypes(), JdbcSimpleTypes.HOLDER);

return new JdbcCustomConversions(CustomConversions.StoreConversions.of(simpleTypeHolder, storeConverters(dialect)),
userConverters());
}

}

@Data
@AllArgsConstructor
@Table("customers")
public static class Customer {

@Id
private Long id;
private String name;
private PersonData personData;
private SessionData sessionData;
}

@Data
@NoArgsConstructor
@AllArgsConstructor
public static class PersonData {
private int age;
private String petName;
}

@Data
@NoArgsConstructor
@AllArgsConstructor
public static class SessionData {
private String token;
private Long ttl;
}

interface CustomerRepository extends CrudRepository<Customer, Long> {

}

@Autowired
CustomerRepository customerRepository;

@Test
void testSaveAndGet() {
final Customer saved = customerRepository.save(new Customer(null, "John Smith", new PersonData(20, "Rex"), new SessionData("j.w.t", 3600L)));
assertThat(saved.getId()).isNotZero();
final Optional<Customer> byId = customerRepository.findById(saved.getId());
assertThat(byId.isPresent()).isTrue();
final Customer foundCustomer = byId.get();
assertThat(foundCustomer.getName()).isEqualTo("John Smith");
assertThat(foundCustomer.getPersonData()).isNotNull();
assertThat(foundCustomer.getPersonData().getAge()).isEqualTo(20);
assertThat(foundCustomer.getPersonData().getPetName()).isEqualTo("Rex");
assertThat(foundCustomer.getSessionData()).isNotNull();
assertThat(foundCustomer.getSessionData().getToken()).isEqualTo("j.w.t");
assertThat(foundCustomer.getSessionData().getTtl()).isEqualTo(3600L);
}

@Test
void testSaveAndGetWithNull() {
final Customer saved = customerRepository.save(new Customer(null, "Adam Smith", new PersonData(30, "Casper"), null));
assertThat(saved.getId()).isNotZero();
final Optional<Customer> byId = customerRepository.findById(saved.getId());
assertThat(byId.isPresent()).isTrue();
final Customer foundCustomer = byId.get();
assertThat(foundCustomer.getName()).isEqualTo("Adam Smith");
assertThat(foundCustomer.getPersonData()).isNotNull();
assertThat(foundCustomer.getPersonData().getAge()).isEqualTo(30);
assertThat(foundCustomer.getPersonData().getPetName()).isEqualTo("Casper");
assertThat(foundCustomer.getSessionData()).isNull();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
DROP TABLE customers;

CREATE TABLE customers (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
person_data JSONB,
session_data JSONB
);
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Set;

import org.springframework.data.relational.core.sql.IdentifierProcessing;
import org.springframework.data.relational.core.sql.LockOptions;
Expand Down Expand Up @@ -203,4 +204,21 @@ public IdentifierProcessing getIdentifierProcessing() {
return IdentifierProcessing.create(Quoting.ANSI, LetterCasing.LOWER_CASE);
}

/*
* (non-Javadoc)
* @see org.springframework.data.relational.core.dialect.Dialect#simpleTypes()
*/
@Override
public Set<Class<?>> simpleTypes() {

if (!ClassUtils.isPresent("org.postgresql.util.PGobject", getClass().getClassLoader())) {
return Collections.emptySet();
}
try {
return Collections.singleton(ClassUtils.forName("org.postgresql.util.PGobject", getClass().getClassLoader()));
} catch (ClassNotFoundException e) {
throw new IllegalStateException(e);
}
}

}
34 changes: 34 additions & 0 deletions src/main/asciidoc/jdbc-custom-conversions.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -78,5 +78,39 @@ Value conversion uses `JdbcValue` to enrich values propagated to JDBC operations
Register a custom write converter if you need to specify a JDBC-specific type instead of using type derivation.
This converter should convert the value to `JdbcValue` which has a field for the value and for the actual `JDBCType`.

=== PostgreSQL JSON[B] support
```kotlin
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.context.annotation.Configuration
import org.springframework.data.convert.ReadingConverter
import org.springframework.data.convert.WritingConverter
import org.springframework.data.jdbc.repository.config.AbstractJdbcConfiguration
import org.springframework.data.jdbc.core.convert.AbstractPostgresJsonReadingConverter
import org.springframework.data.jdbc.core.convert.AbstractPostgresJsonWritingConverter

@Configuration
class JsonJdbcConfig(private val objectMapper: ObjectMapper) : AbstractJdbcConfiguration() {

override fun userConverters(): List<*> {
return mutableListOf(
PersonDataWritingConverter(objectMapper), PersonDataReadingConverter(objectMapper),
SessionDataWritingConverter(objectMapper), SessionDataReadingConverter(objectMapper)
)
}
}

@WritingConverter
class PersonDataWritingConverter(objectMapper: ObjectMapper) : AbstractPostgresJsonWritingConverter<PersonData>(objectMapper, true)

@ReadingConverter
class PersonDataReadingConverter(objectMapper: ObjectMapper) : AbstractPostgresJsonReadingConverter<PersonData>(objectMapper, PersonData::class.java)

@WritingConverter
class SessionDataWritingConverter(objectMapper: ObjectMapper) : AbstractPostgresJsonWritingConverter<SessionData>(objectMapper, true)

@ReadingConverter
class SessionDataReadingConverter(objectMapper: ObjectMapper) : AbstractPostgresJsonReadingConverter<SessionData>(objectMapper, SessionData::class.java)
```


include::{spring-data-commons-docs}/custom-conversions.adoc[leveloffset=+3]