diff --git a/pom.xml b/pom.xml index eeaa0b9e93..8665dcb5a2 100644 --- a/pom.xml +++ b/pom.xml @@ -38,6 +38,7 @@ 8.0.23 42.2.19 19.6.0.0 + 2.12.3 4.0.3 diff --git a/spring-data-jdbc/pom.xml b/spring-data-jdbc/pom.xml index af9ad0904e..5de0c066bc 100644 --- a/spring-data-jdbc/pom.xml +++ b/spring-data-jdbc/pom.xml @@ -144,6 +144,20 @@ true + + com.fasterxml.jackson.core + jackson-databind + ${jackson.databind.version} + true + + + + org.postgresql + postgresql + ${postgresql.version} + true + + org.hsqldb hsqldb @@ -172,13 +186,6 @@ test - - org.postgresql - postgresql - ${postgresql.version} - test - - org.mariadb.jdbc mariadb-java-client diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/AbstractPostgresJsonReadingConverter.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/AbstractPostgresJsonReadingConverter.java new file mode 100644 index 0000000000..b7fe632c9c --- /dev/null +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/AbstractPostgresJsonReadingConverter.java @@ -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 implements Converter { + private final ObjectMapper objectMapper; + private final Class valueType; + + public AbstractPostgresJsonReadingConverter(ObjectMapper objectMapper, Class 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); + } + } +} diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/AbstractPostgresJsonWritingConverter.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/AbstractPostgresJsonWritingConverter.java new file mode 100644 index 0000000000..e380b790b1 --- /dev/null +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/AbstractPostgresJsonWritingConverter.java @@ -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 implements Converter { + 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); + } + } +} diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/PostgresJsonConvertersIntegrationTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/PostgresJsonConvertersIntegrationTests.java new file mode 100644 index 0000000000..eaee709ae8 --- /dev/null +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/PostgresJsonConvertersIntegrationTests.java @@ -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 { + + public PersonDataWritingConverter(ObjectMapper objectMapper) { + super(objectMapper, true); + } + } + + @ReadingConverter + static class PersonDataReadingConverter extends AbstractPostgresJsonReadingConverter { + public PersonDataReadingConverter(ObjectMapper objectMapper) { + super(objectMapper, PersonData.class); + } + } + + @WritingConverter + static class SessionDataWritingConverter extends AbstractPostgresJsonWritingConverter { + public SessionDataWritingConverter(ObjectMapper objectMapper) { + super(objectMapper, true); + } + } + + @ReadingConverter + static class SessionDataReadingConverter extends AbstractPostgresJsonReadingConverter { + public SessionDataReadingConverter(ObjectMapper objectMapper) { + super(objectMapper, SessionData.class); + } + } + + private List storeConverters(Dialect dialect) { + + List converters = new ArrayList<>(); + converters.addAll(dialect.getConverters()); + converters.addAll(JdbcCustomConversions.storeConverters()); + return converters; + } + + protected List userConverters() { + final List 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 { + + } + + @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 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 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(); + } + +} diff --git a/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.core.convert/PostgresJsonConvertersIntegrationTests-postgres.sql b/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.core.convert/PostgresJsonConvertersIntegrationTests-postgres.sql new file mode 100644 index 0000000000..03a516b3f0 --- /dev/null +++ b/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.core.convert/PostgresJsonConvertersIntegrationTests-postgres.sql @@ -0,0 +1,8 @@ +DROP TABLE customers; + +CREATE TABLE customers ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL, + person_data JSONB, + session_data JSONB +); diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/PostgresDialect.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/PostgresDialect.java index 6c93a52d18..efcda4069b 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/PostgresDialect.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/PostgresDialect.java @@ -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; @@ -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> 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); + } + } + } diff --git a/src/main/asciidoc/jdbc-custom-conversions.adoc b/src/main/asciidoc/jdbc-custom-conversions.adoc index 2a7a1b2f5f..46e641cbc4 100644 --- a/src/main/asciidoc/jdbc-custom-conversions.adoc +++ b/src/main/asciidoc/jdbc-custom-conversions.adoc @@ -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(objectMapper, true) + +@ReadingConverter +class PersonDataReadingConverter(objectMapper: ObjectMapper) : AbstractPostgresJsonReadingConverter(objectMapper, PersonData::class.java) + +@WritingConverter +class SessionDataWritingConverter(objectMapper: ObjectMapper) : AbstractPostgresJsonWritingConverter(objectMapper, true) + +@ReadingConverter +class SessionDataReadingConverter(objectMapper: ObjectMapper) : AbstractPostgresJsonReadingConverter(objectMapper, SessionData::class.java) +``` + include::{spring-data-commons-docs}/custom-conversions.adoc[leveloffset=+3]