diff --git a/hibernate-reactive-core/src/main/java/org/hibernate/reactive/adaptor/impl/ResultSetAdaptor.java b/hibernate-reactive-core/src/main/java/org/hibernate/reactive/adaptor/impl/ResultSetAdaptor.java index c9a021a4a..0bfcd89d5 100644 --- a/hibernate-reactive-core/src/main/java/org/hibernate/reactive/adaptor/impl/ResultSetAdaptor.java +++ b/hibernate-reactive-core/src/main/java/org/hibernate/reactive/adaptor/impl/ResultSetAdaptor.java @@ -1323,10 +1323,25 @@ private static class MetaData implements ResultSetMetaData { private final List columns; private final List descriptors; + private final String[] typeNames; + public MetaData(List columnNames, List columnDescriptors) { columns = columnNames; descriptors = columnDescriptors; + typeNames = initTypeNames( columnDescriptors ); + } + + private static String[] initTypeNames(List columnDescriptors) { + if ( columnDescriptors == null ) { + return null; + } + final String[] typeNames = new String[columnDescriptors.size()]; + int i = 0; + for ( ColumnDescriptor columnDescriptor : columnDescriptors ) { + typeNames[i++] = columnDescriptor.typeName(); + } + return typeNames; } @Override @@ -1412,9 +1427,7 @@ public String getCatalogName(int column) { @Override public String getColumnTypeName(int column) { - // This information is in rows.columnDescriptors().get( column-1 ).dataType.name - // but does not appear to be accessible. - return null; + return typeNames[column - 1]; } @Override diff --git a/hibernate-reactive-core/src/main/java/org/hibernate/reactive/provider/impl/ReactiveTypeContributor.java b/hibernate-reactive-core/src/main/java/org/hibernate/reactive/provider/impl/ReactiveTypeContributor.java index 5cabd8710..e278bd393 100644 --- a/hibernate-reactive-core/src/main/java/org/hibernate/reactive/provider/impl/ReactiveTypeContributor.java +++ b/hibernate-reactive-core/src/main/java/org/hibernate/reactive/provider/impl/ReactiveTypeContributor.java @@ -28,9 +28,11 @@ import org.hibernate.engine.jdbc.env.spi.JdbcEnvironment; import org.hibernate.reactive.adaptor.impl.PreparedStatementAdaptor; import org.hibernate.reactive.type.descriptor.jdbc.ReactiveArrayJdbcTypeConstructor; +import org.hibernate.reactive.type.descriptor.jdbc.ReactiveJsonJdbcType; import org.hibernate.service.ServiceRegistry; import org.hibernate.type.AbstractSingleColumnStandardBasicType; import org.hibernate.type.BasicTypeRegistry; +import org.hibernate.type.SqlTypes; import org.hibernate.type.descriptor.ValueBinder; import org.hibernate.type.descriptor.ValueExtractor; import org.hibernate.type.descriptor.WrapperOptions; @@ -83,6 +85,7 @@ private void registerReactiveChanges(TypeContributions typeContributions, Servic JdbcTypeRegistry jdbcTypeRegistry = typeConfiguration.getJdbcTypeRegistry(); jdbcTypeRegistry.addTypeConstructor( ReactiveArrayJdbcTypeConstructor.INSTANCE ); + jdbcTypeRegistry.addDescriptor( SqlTypes.JSON, ReactiveJsonJdbcType.INSTANCE ); if ( dialect instanceof MySQLDialect || dialect instanceof DB2Dialect || dialect instanceof OracleDialect ) { jdbcTypeRegistry.addDescriptor( TimestampAsLocalDateTimeJdbcType.INSTANCE ); diff --git a/hibernate-reactive-core/src/main/java/org/hibernate/reactive/type/descriptor/jdbc/ReactiveJsonJdbcType.java b/hibernate-reactive-core/src/main/java/org/hibernate/reactive/type/descriptor/jdbc/ReactiveJsonJdbcType.java new file mode 100644 index 000000000..1c31755bb --- /dev/null +++ b/hibernate-reactive-core/src/main/java/org/hibernate/reactive/type/descriptor/jdbc/ReactiveJsonJdbcType.java @@ -0,0 +1,92 @@ +/* Hibernate, Relational Persistence for Idiomatic Java + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright: Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.reactive.type.descriptor.jdbc; + +import java.sql.CallableStatement; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +import org.hibernate.metamodel.mapping.EmbeddableMappingType; +import org.hibernate.metamodel.spi.RuntimeModelCreationContext; +import org.hibernate.type.descriptor.ValueBinder; +import org.hibernate.type.descriptor.ValueExtractor; +import org.hibernate.type.descriptor.WrapperOptions; +import org.hibernate.type.descriptor.java.JavaType; +import org.hibernate.type.descriptor.jdbc.AggregateJdbcType; +import org.hibernate.type.descriptor.jdbc.BasicBinder; +import org.hibernate.type.descriptor.jdbc.BasicExtractor; +import org.hibernate.type.descriptor.jdbc.JsonJdbcType; + +import io.vertx.core.json.JsonObject; + +/** + * Map a JSON column as {@link JsonObject} + */ +public class ReactiveJsonJdbcType extends JsonJdbcType { + + public static final ReactiveJsonJdbcType INSTANCE = new ReactiveJsonJdbcType( null ); + + protected ReactiveJsonJdbcType(EmbeddableMappingType embeddableMappingType) { + super( embeddableMappingType ); + } + + @Override + public AggregateJdbcType resolveAggregateJdbcType( + EmbeddableMappingType mappingType, String sqlType, RuntimeModelCreationContext creationContext) { + return new ReactiveJsonJdbcType( mappingType ); + } + + @Override + public ValueBinder getBinder(JavaType javaType) { + return new BasicBinder<>( javaType, this ) { + @Override + protected void doBind(PreparedStatement st, X value, int index, WrapperOptions options) + throws SQLException { + st.setObject( index, toJsonObject( value, javaType, options ) ); + } + + @Override + protected void doBind(CallableStatement st, X value, String name, WrapperOptions options) + throws SQLException { + st.setObject( name, toJsonObject( value, javaType, options ) ); + } + }; + } + + protected JsonObject toJsonObject(X value, JavaType javaType, WrapperOptions options) { + return new JsonObject( this.toString( value, javaType, options ) ); + } + + @Override + public ValueExtractor getExtractor(JavaType javaType) { + return new BasicExtractor<>( javaType, this ) { + @Override + protected X doExtract(ResultSet rs, int paramIndex, WrapperOptions options) throws SQLException { + return fromString( toJsonString( rs.getObject( paramIndex ) ), getJavaType(), options ); + } + + @Override + protected X doExtract(CallableStatement statement, int index, WrapperOptions options) throws SQLException { + return fromString( toJsonString( statement.getObject( index ) ), getJavaType(), options ); + } + + @Override + protected X doExtract(CallableStatement statement, String name, WrapperOptions options) + throws SQLException { + return fromString( toJsonString( statement.getObject( name ) ), getJavaType(), options ); + } + }; + } + + private static String toJsonString(Object value) { + if ( value == null ) { + return null; + } + // Value should be a JsonObject + return value.toString(); + } +} diff --git a/hibernate-reactive-core/src/test/java/org/hibernate/reactive/QueryTest.java b/hibernate-reactive-core/src/test/java/org/hibernate/reactive/QueryTest.java index 00e42e82e..d6146b2e5 100644 --- a/hibernate-reactive-core/src/test/java/org/hibernate/reactive/QueryTest.java +++ b/hibernate-reactive-core/src/test/java/org/hibernate/reactive/QueryTest.java @@ -5,12 +5,14 @@ */ package org.hibernate.reactive; +import java.math.BigDecimal; import java.sql.Timestamp; import java.time.LocalDateTime; import java.time.OffsetDateTime; import java.util.ArrayList; import java.util.Collection; import java.util.List; +import java.util.Objects; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -60,6 +62,60 @@ protected Collection> annotatedEntities() { return List.of( Book.class, Author.class ); } + private final static BigDecimal PIE = BigDecimal.valueOf( 3.1416 ); + private final static BigDecimal TAO = BigDecimal.valueOf( 6.2832 ); + + @Test + public void testBigDecimalAsParameter(VertxTestContext context) { + Author author1 = new Author( "Iain M. Banks" ); + Author author2 = new Author( "Neal Stephenson" ); + Book book1 = new Book( "1-85723-235-6", "Feersum Endjinn", author1 ); + book1.quantity = BigDecimal.valueOf( 11.2 ); + Book book2 = new Book( "0-380-97346-4", "Cryptonomicon", author2 ); + book2.quantity = PIE; + Book book3 = new Book( "0-553-08853-X", "Snow Crash", author2 ); + book3.quantity = TAO; + + author1.books.add( book1 ); + author2.books.add( book2 ); + author2.books.add( book3 ); + + test( context, getMutinySessionFactory() + .withTransaction( s -> s.persistAll( author1, author2 ) ) + // HQL with named parameters + .chain( () -> getMutinySessionFactory().withTransaction( s -> s + .createSelectionQuery( "from Book where quantity > :quantity", Book.class ) + .setParameter( "quantity", PIE ) + .getResultList() + .invoke( result -> assertThat( result ).containsExactlyInAnyOrder( book1, book3 ) ) + ) ) + // HQL with positional parameters + .chain( () -> getMutinySessionFactory().withTransaction( s -> s + .createSelectionQuery( "from Book where quantity > ?1", Book.class ) + .setParameter( 1, PIE ) + .getResultList() + .invoke( result -> assertThat( result ).containsExactlyInAnyOrder( book1, book3 ) ) + ) ) + // Criteria + .call( () -> { + CriteriaBuilder builder = getSessionFactory().getCriteriaBuilder(); + CriteriaQuery query = builder.createQuery( Book.class ); + Root b = query.from( Book.class ); + b.fetch( "author" ); + query.where( builder.between( + b.get( "quantity" ), + BigDecimal.valueOf( 4.0 ), + BigDecimal.valueOf( 100.0 ) + ) ); + return getMutinySessionFactory().withTransaction( s -> s + .createQuery( query ) + .getResultList() + .invoke( result -> assertThat( result ).containsExactlyInAnyOrder( book1, book3 ) ) + ); + } ) + ); + } + @Test public void testCriteriaEntityQuery(VertxTestContext context) { Author author1 = new Author( "Iain M. Banks" ); @@ -711,6 +767,28 @@ static class Author { Author() { } + + @Override + public String toString() { + return id + ":" + name; + } + + @Override + public boolean equals(Object o) { + if ( this == o ) { + return true; + } + if ( o == null || getClass() != o.getClass() ) { + return false; + } + Author author = (Author) o; + return Objects.equals( name, author.name ); + } + + @Override + public int hashCode() { + return Objects.hashCode( name ); + } } @Entity(name = "Book") @@ -729,6 +807,8 @@ static class Book { @ManyToOne(fetch = LAZY) Author author; + BigDecimal quantity; + Book(String isbn, String title, Author author) { this.title = title; this.isbn = isbn; @@ -737,6 +817,31 @@ static class Book { Book() { } + + @Override + public String toString() { + return id + ":" + title + ":" + isbn + ":" + quantity; + } + + @Override + public boolean equals(Object o) { + if ( this == o ) { + return true; + } + if ( o == null || getClass() != o.getClass() ) { + return false; + } + Book book = (Book) o; + return Objects.equals( isbn, book.isbn ) && Objects.equals( + title, + book.title + ); + } + + @Override + public int hashCode() { + return Objects.hash( isbn, title ); + } } } diff --git a/hibernate-reactive-core/src/test/java/org/hibernate/reactive/types/JsonQueryTest.java b/hibernate-reactive-core/src/test/java/org/hibernate/reactive/types/JsonQueryTest.java new file mode 100644 index 000000000..7af5d42c7 --- /dev/null +++ b/hibernate-reactive-core/src/test/java/org/hibernate/reactive/types/JsonQueryTest.java @@ -0,0 +1,256 @@ +/* Hibernate, Relational Persistence for Idiomatic Java + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright: Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.reactive.types; + +import java.math.BigDecimal; +import java.util.Collection; +import java.util.List; +import java.util.Objects; + +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.reactive.BaseReactiveTest; +import org.hibernate.reactive.annotations.EnabledFor; +import org.hibernate.type.SqlTypes; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import io.vertx.core.json.JsonObject; +import io.vertx.junit5.Timeout; +import io.vertx.junit5.VertxTestContext; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Root; + +import static java.util.concurrent.TimeUnit.MINUTES; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hibernate.reactive.containers.DatabaseConfiguration.DBType.POSTGRESQL; + +@Timeout(value = 10, timeUnit = MINUTES) +@EnabledFor(POSTGRESQL) +public class JsonQueryTest extends BaseReactiveTest { + + private final static BigDecimal PIE = BigDecimal.valueOf( 3.1416 ); + private final static BigDecimal TAO = BigDecimal.valueOf( 6.2832 ); + + final Book fakeHistory = new Book( 3, "Fake History", new JsonObject().put( "amount", PIE ), new Book.Author( "Jo", "Hedwig Teeuwisse" ) ); + final Book theBookOfM = new Book( 5, "The Book of M", new JsonObject().put( "amount", TAO ), new Book.Author( "Peng", "Shepherd" ) ); + + @Override + protected Collection> annotatedEntities() { + return List.of( Book.class ); + } + + @BeforeEach + public void populateDb(VertxTestContext context) { + test( context, getMutinySessionFactory().withTransaction( s -> s.persistAll( theBookOfM, fakeHistory ) ) ); + } + + @Test + public void criteriaSelectAll(VertxTestContext context) { + CriteriaBuilder cb = getMutinySessionFactory().getCriteriaBuilder(); + CriteriaQuery bookQuery = cb.createQuery( Book.class ); + bookQuery.from( Book.class ); + test( context, getMutinySessionFactory() + .withTransaction( s -> s.createQuery( bookQuery ).getResultList() ) + .invoke( results -> assertThat( results ).containsExactlyInAnyOrder( fakeHistory, theBookOfM ) ) + ); + } + + @Test + public void criteriaQueryWithJsonbAndFunction(VertxTestContext context) { + CriteriaBuilder cb = getMutinySessionFactory().getCriteriaBuilder(); + CriteriaQuery bookQuery = cb.createQuery( Book.class ); + Root bookRoot = bookQuery.from( Book.class ); + bookQuery.where( cb.equal( + cb.function( "jsonb_extract_path_text", String.class, bookRoot.get( "author" ), cb.literal( "name" ) ), + cb.literal( fakeHistory.author.name ) + ) ); + + test( context, getMutinySessionFactory() + .withTransaction( s -> s.createQuery( bookQuery ).getSingleResult() ) + .invoke( result -> assertThat( result ).isEqualTo( fakeHistory ) ) + ); + } + + @Test + public void criteriaQueryWithJson(VertxTestContext context) { + CriteriaBuilder cb = getMutinySessionFactory().getCriteriaBuilder(); + CriteriaQuery bookQuery = cb.createQuery( Book.class ); + bookQuery.from( Book.class ); + bookQuery.where( cb.between( + cb.function( "sql", BigDecimal.class, cb.literal( "(price ->> ?)::decimal" ), cb.literal( "amount" ) ), + BigDecimal.valueOf( 4.0 ), + BigDecimal.valueOf( 100.0 ) + ) ); + + test( context, getMutinySessionFactory() + .withTransaction( s -> s.createQuery( bookQuery ).getSingleResult() ) + .invoke( result -> assertThat( result ).isEqualTo( theBookOfM ) ) + ); + } + + @Test + public void hqlQueryWithJson(VertxTestContext context) { + test( context, getMutinySessionFactory() + .withTransaction( s -> s + .createSelectionQuery( + "from Book where sql('(price ->> ?)::decimal', 'amount') between ?1 and ?2", + Book.class + ) + .setParameter( 1, BigDecimal.valueOf( 4.0 ) ) + .setParameter( 2, BigDecimal.valueOf( 100.0 ) ) + .getSingleResult() + ) + .invoke( result -> assertThat( result ).isEqualTo( theBookOfM ) ) + ); + } + + @Disabled("https://github.com/hibernate/hibernate-reactive/issues/1999") + @Test + public void nativeSelectAll(VertxTestContext context) { + test( context, getMutinySessionFactory() + .withTransaction( s -> s.createNativeQuery( "select * from BookWithJson", Book.class ).getResultList() ) + .invoke( results -> assertThat( results ).containsExactlyInAnyOrder( fakeHistory, theBookOfM ) ) + ); + } + + @Disabled("https://github.com/hibernate/hibernate-reactive/issues/1999") + @Test + public void nativeSelectWithoutResultType(VertxTestContext context) { + test( context, getMutinySessionFactory() + .withTransaction( s -> s.createNativeQuery( "select * from BookWithJson" ).getResultList() ) + .invoke( results -> assertThat( results ).containsExactlyInAnyOrder( fakeHistory, theBookOfM ) ) + ); + } + + @Disabled("https://github.com/hibernate/hibernate-reactive/issues/1999") + @Test + public void nativeQueryWithJson(VertxTestContext context) { + test( context, getMutinySessionFactory() + .withTransaction( s -> s + .createNativeQuery( + "select * from BookWithJson b where (b.price ->> 'amount')::decimal between ?1 and ?2", + Book.class + ) + .setParameter( 1, BigDecimal.valueOf( 4.0 ) ) + .setParameter( 2, BigDecimal.valueOf( 100.0 ) ) + .getSingleResult() + ) + .invoke( result -> assertThat( result ).isEqualTo( theBookOfM ) ) + ); + } + + @Entity(name = "Book") + @Table(name = "BookWithJson") + public static class Book { + + @Id + Integer id; + + String title; + + @Column(name = "price") + JsonObject price; + + @JdbcTypeCode(SqlTypes.JSON) + Author author; + + public Book() { + } + + public Book(Integer id, String title, JsonObject price, Author author) { + this.id = id; + this.title = title; + this.price = price; + this.author = author; + } + + @Override + public boolean equals(Object o) { + if ( this == o ) { + return true; + } + if ( o == null || getClass() != o.getClass() ) { + return false; + } + Book book = (Book) o; + return Objects.equals( id, book.id ) && Objects.equals( + title, + book.title + ) && Objects.equals( price, book.price ) && Objects.equals( author, book.author ); + } + + @Override + public int hashCode() { + return Objects.hash( id, title, price, author ); + } + + @Override + public String toString() { + return id + ":" + title + ":" + price + ":" + author; + } + + + @Embeddable + public static class Author { + private String name; + private String surname; + + public Author() { + } + + public Author(String name, String surname) { + this.name = name; + this.surname = surname; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getSurname() { + return surname; + } + + public void setSurname(String surname) { + this.surname = surname; + } + + @Override + public boolean equals(Object o) { + if ( this == o ) { + return true; + } + if ( o == null || getClass() != o.getClass() ) { + return false; + } + Author author = (Author) o; + return Objects.equals( name, author.name ) && Objects.equals( surname, author.surname ); + } + + @Override + public int hashCode() { + return Objects.hash( name, surname ); + } + + @Override + public String toString() { + return name + ' ' + surname; + } + } + } +}