diff --git a/src/main/java/org/springframework/data/r2dbc/dialect/ArrayColumns.java b/src/main/java/org/springframework/data/r2dbc/dialect/ArrayColumns.java new file mode 100644 index 0000000..5259af2 --- /dev/null +++ b/src/main/java/org/springframework/data/r2dbc/dialect/ArrayColumns.java @@ -0,0 +1,53 @@ +package org.springframework.data.r2dbc.dialect; + +/** + * Interface declaring methods that express how a dialect supports array-typed columns. + * + * @author Mark Paluch + */ +public interface ArrayColumns { + + /** + * Returns {@literal true} if the dialect supports array-typed columns. + * + * @return {@literal true} if the dialect supports array-typed columns. + */ + boolean isSupported(); + + /** + * Translate the {@link Class user type} of an array into the dialect-specific type. This method considers only the + * component type. + * + * @param userType component type of the array. + * @return the dialect-supported array type. + * @throws UnsupportedOperationException if array typed columns are not supported. + * @throws IllegalArgumentException if the {@code userType} is not a supported array type. + */ + Class getArrayType(Class userType); + + /** + * Default {@link ArrayColumns} implementation for dialects that do not support array-typed columns. + */ + enum Unsupported implements ArrayColumns { + + INSTANCE; + + /* + * (non-Javadoc) + * @see org.springframework.data.r2dbc.dialect.ArrayColumns#isSupported() + */ + @Override + public boolean isSupported() { + return false; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.r2dbc.dialect.ArrayColumns#getArrayType(java.lang.Class) + */ + @Override + public Class getArrayType(Class userType) { + throw new UnsupportedOperationException("Array types not supported"); + } + } +} diff --git a/src/main/java/org/springframework/data/r2dbc/dialect/Dialect.java b/src/main/java/org/springframework/data/r2dbc/dialect/Dialect.java index 411f383..e909f72 100644 --- a/src/main/java/org/springframework/data/r2dbc/dialect/Dialect.java +++ b/src/main/java/org/springframework/data/r2dbc/dialect/Dialect.java @@ -5,6 +5,7 @@ import java.util.Collections; import java.util.HashSet; import org.springframework.data.mapping.model.SimpleTypeHolder; +import org.springframework.data.r2dbc.dialect.ArrayColumns.Unsupported; /** * Represents a dialect that is implemented by a particular database. @@ -61,12 +62,11 @@ public interface Dialect { LimitClause limit(); /** - * Returns {@literal true} whether this dialect supports array-typed column. Collection-typed columns can map their - * content to native array types. + * Returns the array support object that describes how array-typed columns are supported by this dialect. * - * @return {@literal true} whether this dialect supports array-typed columns. + * @return the array support object that describes how array-typed columns are supported by this dialect. */ - default boolean supportsArrayColumns() { - return false; + default ArrayColumns getArraySupport() { + return Unsupported.INSTANCE; } } diff --git a/src/main/java/org/springframework/data/r2dbc/dialect/PostgresDialect.java b/src/main/java/org/springframework/data/r2dbc/dialect/PostgresDialect.java index 8d0c0b3..d0b1bf0 100644 --- a/src/main/java/org/springframework/data/r2dbc/dialect/PostgresDialect.java +++ b/src/main/java/org/springframework/data/r2dbc/dialect/PostgresDialect.java @@ -1,15 +1,20 @@ package org.springframework.data.r2dbc.dialect; +import lombok.RequiredArgsConstructor; + import java.net.InetAddress; import java.net.URI; import java.net.URL; import java.util.Arrays; import java.util.Collection; import java.util.HashSet; -import java.util.List; import java.util.Set; import java.util.UUID; +import org.springframework.data.mapping.model.SimpleTypeHolder; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; + /** * An SQL dialect for Postgres. * @@ -18,7 +23,7 @@ import java.util.UUID; public class PostgresDialect implements Dialect { private static final Set> SIMPLE_TYPES = new HashSet<>( - Arrays.asList(List.class, Collection.class, String[].class, UUID.class, URL.class, URI.class, InetAddress.class)); + Arrays.asList(UUID.class, URL.class, URI.class, InetAddress.class)); /** * Singleton instance. @@ -57,6 +62,8 @@ public class PostgresDialect implements Dialect { } }; + private final PostgresArrayColumns ARRAY_COLUMNS = new PostgresArrayColumns(getSimpleTypeHolder()); + /* * (non-Javadoc) * @see org.springframework.data.r2dbc.dialect.Dialect#getBindMarkersFactory() @@ -95,10 +102,41 @@ public class PostgresDialect implements Dialect { /* * (non-Javadoc) - * @see org.springframework.data.r2dbc.dialect.Dialect#supportsArrayColumns() + * @see org.springframework.data.r2dbc.dialect.Dialect#getArraySupport() */ @Override - public boolean supportsArrayColumns() { - return true; + public ArrayColumns getArraySupport() { + return ARRAY_COLUMNS; + } + + @RequiredArgsConstructor + static class PostgresArrayColumns implements ArrayColumns { + + private final SimpleTypeHolder simpleTypes; + + /* + * (non-Javadoc) + * @see org.springframework.data.r2dbc.dialect.ArrayColumns#isSupported() + */ + @Override + public boolean isSupported() { + return true; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.r2dbc.dialect.ArrayColumns#getArrayType(java.lang.Class) + */ + @Override + public Class getArrayType(Class userType) { + + Assert.notNull(userType, "Array component type must not be null"); + + if (!simpleTypes.isSimpleType(userType)) { + throw new IllegalArgumentException("Unsupported array type: " + ClassUtils.getQualifiedName(userType)); + } + + return ClassUtils.resolvePrimitiveIfNecessary(userType); + } } } diff --git a/src/main/java/org/springframework/data/r2dbc/function/DefaultReactiveDataAccessStrategy.java b/src/main/java/org/springframework/data/r2dbc/function/DefaultReactiveDataAccessStrategy.java index abe26fc..537dcdd 100644 --- a/src/main/java/org/springframework/data/r2dbc/function/DefaultReactiveDataAccessStrategy.java +++ b/src/main/java/org/springframework/data/r2dbc/function/DefaultReactiveDataAccessStrategy.java @@ -38,6 +38,7 @@ import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort.Order; import org.springframework.data.mapping.PersistentPropertyAccessor; import org.springframework.data.mapping.context.MappingContext; +import org.springframework.data.r2dbc.dialect.ArrayColumns; import org.springframework.data.r2dbc.dialect.BindMarker; import org.springframework.data.r2dbc.dialect.BindMarkers; import org.springframework.data.r2dbc.dialect.Dialect; @@ -249,7 +250,7 @@ public class DefaultReactiveDataAccessStrategy implements ReactiveDataAccessStra private Object getWriteValue(PersistentPropertyAccessor propertyAccessor, RelationalPersistentProperty property) { TypeInformation type = property.getTypeInformation(); - Object value = relationalConverter.writeValue(propertyAccessor.getProperty(property), type); + Object value = propertyAccessor.getProperty(property); if (type.isCollectionLike()) { @@ -260,17 +261,28 @@ public class DefaultReactiveDataAccessStrategy implements ReactiveDataAccessStra throw new InvalidDataAccessApiUsageException("Nested entities are not supported"); } - if (!dialect.supportsArrayColumns()) { + ArrayColumns arrayColumns = dialect.getArraySupport(); + + if (!arrayColumns.isSupported()) { throw new InvalidDataAccessResourceUsageException( "Dialect " + dialect.getClass().getName() + " does not support array columns"); } - if (!property.isArray()) { + return getArrayValue(arrayColumns, property, value); + } - Object zeroLengthArray = Array.newInstance(property.getActualType(), 0); - return relationalConverter.getConversionService().convert(value, zeroLengthArray.getClass()); - } + return value; + } + + private Object getArrayValue(ArrayColumns arrayColumns, RelationalPersistentProperty property, Object value) { + + Class targetType = arrayColumns.getArrayType(property.getActualType()); + + if (!property.isArray() || !property.getActualType().equals(targetType)) { + + Object zeroLengthArray = Array.newInstance(targetType, 0); + return relationalConverter.getConversionService().convert(value, zeroLengthArray.getClass()); } return value; diff --git a/src/main/java/org/springframework/data/r2dbc/function/convert/EntityRowMapper.java b/src/main/java/org/springframework/data/r2dbc/function/convert/EntityRowMapper.java index 2f9e89c..515718c 100644 --- a/src/main/java/org/springframework/data/r2dbc/function/convert/EntityRowMapper.java +++ b/src/main/java/org/springframework/data/r2dbc/function/convert/EntityRowMapper.java @@ -96,7 +96,8 @@ public class EntityRowMapper implements BiFunction { return readEntityFrom(row, property); } - return converter.readValue(row.get(prefix + property.getColumnName()), property.getTypeInformation()); + Object value = row.get(prefix + property.getColumnName()); + return converter.readValue(value, property.getTypeInformation()); } catch (Exception o_O) { throw new MappingException(String.format("Could not read property %s from result set!", property), o_O); @@ -156,9 +157,7 @@ public class EntityRowMapper implements BiFunction { String column = prefix + property.getColumnName(); try { - - Object value = converter.readValue(resultSet.get(column), property.getTypeInformation()); - return converter.getConversionService().convert(value, parameter.getType().getType()); + return converter.getConversionService().convert(resultSet.get(column), parameter.getType().getType()); } catch (Exception o_O) { throw new MappingException(String.format("Couldn't read column %s from Row.", column), o_O); } diff --git a/src/test/java/org/springframework/data/r2dbc/dialect/PostgresDialectUnitTests.java b/src/test/java/org/springframework/data/r2dbc/dialect/PostgresDialectUnitTests.java index 7df2e22..e1fe860 100644 --- a/src/test/java/org/springframework/data/r2dbc/dialect/PostgresDialectUnitTests.java +++ b/src/test/java/org/springframework/data/r2dbc/dialect/PostgresDialectUnitTests.java @@ -1,8 +1,8 @@ package org.springframework.data.r2dbc.dialect; import static org.assertj.core.api.Assertions.*; +import static org.assertj.core.api.SoftAssertions.*; -import java.util.Collection; import java.util.List; import org.junit.Test; @@ -28,35 +28,50 @@ public class PostgresDialectUnitTests { } @Test // gh-30 - public void shouldConsiderCollectionTypesAsSimple() { + public void shouldConsiderSimpleTypes() { SimpleTypeHolder holder = PostgresDialect.INSTANCE.getSimpleTypeHolder(); - assertThat(holder.isSimpleType(List.class)).isTrue(); - assertThat(holder.isSimpleType(Collection.class)).isTrue(); + assertSoftly(it -> { + it.assertThat(holder.isSimpleType(String.class)).isTrue(); + it.assertThat(holder.isSimpleType(int.class)).isTrue(); + it.assertThat(holder.isSimpleType(Integer.class)).isTrue(); + }); } @Test // gh-30 - public void shouldConsiderStringArrayTypeAsSimple() { + public void shouldSupportArrays() { - SimpleTypeHolder holder = PostgresDialect.INSTANCE.getSimpleTypeHolder(); + ArrayColumns arrayColumns = PostgresDialect.INSTANCE.getArraySupport(); - assertThat(holder.isSimpleType(String[].class)).isTrue(); + assertThat(arrayColumns.isSupported()).isTrue(); + } - @Test // gh-30 - public void shouldConsiderIntArrayTypeAsSimple() { + @Test // gh-30 + public void shouldUseBoxedArrayTypesForPrimitiveTypes() { - SimpleTypeHolder holder = PostgresDialect.INSTANCE.getSimpleTypeHolder(); + ArrayColumns arrayColumns = PostgresDialect.INSTANCE.getArraySupport(); - assertThat(holder.isSimpleType(int[].class)).isTrue(); - } + assertSoftly(it -> { + it.assertThat(arrayColumns.getArrayType(int.class)).isEqualTo(Integer.class); + it.assertThat(arrayColumns.getArrayType(double.class)).isEqualTo(Double.class); + it.assertThat(arrayColumns.getArrayType(String.class)).isEqualTo(String.class); + }); + } - @Test // gh-30 - public void shouldConsiderIntegerArrayTypeAsSimple() { + @Test // gh-30 + public void shouldRejectNonSimpleArrayTypes() { - SimpleTypeHolder holder = PostgresDialect.INSTANCE.getSimpleTypeHolder(); + ArrayColumns arrayColumns = PostgresDialect.INSTANCE.getArraySupport(); - assertThat(holder.isSimpleType(Integer[].class)).isTrue(); - } + assertThatThrownBy(() -> arrayColumns.getArrayType(getClass())).isInstanceOf(IllegalArgumentException.class); + } + + @Test // gh-30 + public void shouldRejectNestedCollections() { + + ArrayColumns arrayColumns = PostgresDialect.INSTANCE.getArraySupport(); + + assertThatThrownBy(() -> arrayColumns.getArrayType(List.class)).isInstanceOf(IllegalArgumentException.class); } } diff --git a/src/test/java/org/springframework/data/r2dbc/dialect/SqlServerDialectUnitTests.java b/src/test/java/org/springframework/data/r2dbc/dialect/SqlServerDialectUnitTests.java index 1e96e38..cb39be6 100644 --- a/src/test/java/org/springframework/data/r2dbc/dialect/SqlServerDialectUnitTests.java +++ b/src/test/java/org/springframework/data/r2dbc/dialect/SqlServerDialectUnitTests.java @@ -33,4 +33,13 @@ public class SqlServerDialectUnitTests { assertThat(holder.isSimpleType(UUID.class)).isTrue(); } + + @Test // gh-30 + public void shouldNotSupportArrays() { + + ArrayColumns arrayColumns = SqlServerDialect.INSTANCE.getArraySupport(); + + assertThat(arrayColumns.isSupported()).isFalse(); + assertThatThrownBy(() -> arrayColumns.getArrayType(String.class)).isInstanceOf(UnsupportedOperationException.class); + } } diff --git a/src/test/java/org/springframework/data/r2dbc/function/PostgresIntegrationTests.java b/src/test/java/org/springframework/data/r2dbc/function/PostgresIntegrationTests.java new file mode 100644 index 0000000..8321edd --- /dev/null +++ b/src/test/java/org/springframework/data/r2dbc/function/PostgresIntegrationTests.java @@ -0,0 +1,147 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.r2dbc.function; + +import static org.assertj.core.api.Assertions.*; + +import io.r2dbc.spi.ConnectionFactory; +import lombok.AllArgsConstructor; +import reactor.test.StepVerifier; + +import java.util.Arrays; +import java.util.List; +import java.util.function.Consumer; + +import javax.sql.DataSource; + +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Ignore; +import org.junit.Test; +import org.springframework.data.r2dbc.testing.ExternalDatabase; +import org.springframework.data.r2dbc.testing.PostgresTestSupport; +import org.springframework.data.r2dbc.testing.R2dbcIntegrationTestSupport; +import org.springframework.data.relational.core.mapping.Table; +import org.springframework.jdbc.core.JdbcTemplate; + +/** + * Integration tests for PostgreSQL-specific features such as array support. + * + * @author Mark Paluch + */ +public class PostgresIntegrationTests extends R2dbcIntegrationTestSupport { + + @ClassRule public static final ExternalDatabase database = PostgresTestSupport.database(); + + DataSource dataSource = PostgresTestSupport.createDataSource(database); + ConnectionFactory connectionFactory = PostgresTestSupport.createConnectionFactory(database); + JdbcTemplate template = createJdbcTemplate(dataSource); + DatabaseClient client = DatabaseClient.create(connectionFactory); + + @Before + public void before() { + + template.execute("DROP TABLE IF EXISTS with_arrays"); + template.execute("CREATE TABLE with_arrays (" // + + "boxed_array INT[]," // + + "primitive_array INT[]," // + + "multidimensional_array INT[]," // + + "collection_array INT[][])"); + } + + @Test // gh-30 + @Ignore("https://github.com/r2dbc/r2dbc-postgresql/issues/40, r2dbc-postgresql returns Object[] instead of Integer[]") + public void shouldReadAndWritePrimitiveSingleDimensionArrays() { + + EntityWithArrays withArrays = new EntityWithArrays(null, new int[] { 1, 2, 3 }, null, null); + + insert(withArrays); + selectAndAssert(actual -> { + assertThat(actual.primitiveArray).containsExactly(1, 2, 3); + }); + } + + @Test // gh-30 + public void shouldReadAndWriteBoxedSingleDimensionArrays() { + + EntityWithArrays withArrays = new EntityWithArrays(new Integer[] { 1, 2, 3 }, null, null, null); + + insert(withArrays); + + selectAndAssert(actual -> { + + assertThat(actual.boxedArray).containsExactly(1, 2, 3); + + }); + } + + @Test // gh-30 + public void shouldReadAndWriteConvertedDimensionArrays() { + + EntityWithArrays withArrays = new EntityWithArrays(null, null, null, Arrays.asList(5, 6, 7)); + + insert(withArrays); + + selectAndAssert(actual -> { + assertThat(actual.collectionArray).containsExactly(5, 6, 7); + }); + } + + @Test // gh-30 + @Ignore("https://github.com/r2dbc/r2dbc-postgresql/issues/42, Multi-dimensional arrays not supported yet") + public void shouldReadAndWriteMultiDimensionArrays() { + + EntityWithArrays withArrays = new EntityWithArrays(null, null, new int[][] { { 1, 2, 3 }, { 4, 5 } }, null); + + insert(withArrays); + + selectAndAssert(actual -> { + + assertThat(actual.multidimensionalArray).hasSize(2); + assertThat(actual.multidimensionalArray[0]).containsExactly(1, 2, 3); + assertThat(actual.multidimensionalArray[1]).containsExactly(4, 5, 6); + }); + } + + private void insert(EntityWithArrays object) { + + client.insert() // + .into(EntityWithArrays.class) // + .using(object) // + .then() // + .as(StepVerifier::create) // + .verifyComplete(); + } + + private void selectAndAssert(Consumer assertion) { + + client.select() // + .from(EntityWithArrays.class).fetch() // + .first() // + .as(StepVerifier::create) // + .consumeNextWith(assertion).verifyComplete(); + } + + @Table("with_arrays") + @AllArgsConstructor + static class EntityWithArrays { + + Integer[] boxedArray; + int[] primitiveArray; + int[][] multidimensionalArray; + List collectionArray; + } +} diff --git a/src/test/java/org/springframework/data/r2dbc/function/convert/EntityRowMapperUnitTests.java b/src/test/java/org/springframework/data/r2dbc/function/convert/EntityRowMapperUnitTests.java index d7023ac..91dceff 100644 --- a/src/test/java/org/springframework/data/r2dbc/function/convert/EntityRowMapperUnitTests.java +++ b/src/test/java/org/springframework/data/r2dbc/function/convert/EntityRowMapperUnitTests.java @@ -8,6 +8,7 @@ import io.r2dbc.spi.RowMetadata; import lombok.RequiredArgsConstructor; import java.util.List; +import java.util.Set; import org.junit.Test; import org.junit.runner.RunWith; @@ -20,6 +21,7 @@ import org.springframework.data.relational.core.mapping.RelationalPersistentEnti * Unit tests for {@link EntityRowMapper}. * * @author Mark Paluch + * @author Jens Schauder */ @RunWith(MockitoJUnitRunner.class) public class EntityRowMapperUnitTests { @@ -73,7 +75,7 @@ public class EntityRowMapperUnitTests { public void shouldConvertArrayToSet() { EntityRowMapper mapper = getRowMapper(EntityWithCollection.class); - when(rowMock.get("integerSet")).thenReturn((new int[] { 3, 14 })); + when(rowMock.get("integer_set")).thenReturn((new int[] { 3, 14 })); EntityWithCollection result = mapper.apply(rowMock, metadata); assertThat(result.integerSet).contains(3, 14); @@ -83,7 +85,7 @@ public class EntityRowMapperUnitTests { public void shouldConvertArrayMembers() { EntityRowMapper mapper = getRowMapper(EntityWithCollection.class); - when(rowMock.get("primitiveIntegers")).thenReturn((new long[] { 3L, 14L })); + when(rowMock.get("primitive_integers")).thenReturn((new Long[] { 3L, 14L })); EntityWithCollection result = mapper.apply(rowMock, metadata); assertThat(result.primitiveIntegers).contains(3, 14); @@ -93,11 +95,12 @@ public class EntityRowMapperUnitTests { public void shouldConvertArrayToBoxedArray() { EntityRowMapper mapper = getRowMapper(EntityWithCollection.class); - when(rowMock.get("boxedIntegers")).thenReturn((new int[] { 3, 11 })); + when(rowMock.get("boxed_integers")).thenReturn((new int[] { 3, 11 })); EntityWithCollection result = mapper.apply(rowMock, metadata); assertThat(result.boxedIntegers).contains(3, 11); } + @SuppressWarnings("unchecked") private EntityRowMapper getRowMapper(Class type) { RelationalPersistentEntity entity = (RelationalPersistentEntity) strategy.getMappingContext()