diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/cql/BeanPropertyRowMapper.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/cql/BeanPropertyRowMapper.java new file mode 100644 index 000000000..53998c9bc --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/cql/BeanPropertyRowMapper.java @@ -0,0 +1,422 @@ +/* + * Copyright 2020 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 + * + * https://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.cassandra.core.cql; + +import java.beans.PropertyDescriptor; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.beans.BeanUtils; +import org.springframework.beans.BeanWrapper; +import org.springframework.beans.BeanWrapperImpl; +import org.springframework.beans.NotWritablePropertyException; +import org.springframework.beans.TypeConverter; +import org.springframework.beans.TypeMismatchException; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.dao.DataRetrievalFailureException; +import org.springframework.dao.InvalidDataAccessApiUsageException; +import org.springframework.data.cassandra.core.convert.CassandraJsr310Converters; +import org.springframework.data.convert.Jsr310Converters; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.StringUtils; + +import com.datastax.oss.driver.api.core.cql.Row; + +/** + * {@link RowMapper} implementation that converts a {@link Row} into a new instance of the specified mapped target + * class. The mapped target class must be a top-level class and it must have a default or no-arg constructor. + *

+ * Column values are mapped based on matching the column name as obtained from result set meta-data to public setters + * for the corresponding properties. The names are matched either directly or by transforming a name separating the + * parts with underscores to the same name using "camel" case. + *

+ * Mapping is provided for fields in the target class for many common types, e.g.: String, boolean, Boolean, byte, Byte, + * short, Short, int, Integer, long, Long, float, Float, double, Double, BigDecimal, {@code java.util.Date}, etc. + *

+ * To facilitate mapping between columns and fields that don't have matching names, try using column aliases in the CQL + * statement like "select fname as first_name from customer". + *

+ * For 'null' values read from the database, we will attempt to call the setter, but in the case of Java primitives, + * this causes a {@link TypeMismatchException}. This class can be configured (using the + * {@code primitivesDefaultedForNullValue} property) to trap this exception and use the primitives default value. Be + * aware that if you use the values from the generated bean to update the database the primitive value will have been + * set to the primitive's default value instead of null. + *

+ * Please note that this class is designed to provide convenience rather than high performance. For best performance, + * consider using a custom {@link RowMapper} implementation. + * + * @author Mark Paluch + * @since 3.1 + * @param the result type + */ +public class BeanPropertyRowMapper implements RowMapper { + + /** Logger available to subclasses. */ + protected final Log logger = LogFactory.getLog(getClass()); + + /** The class we are mapping to. */ + private @Nullable Class mappedClass; + + /** Whether we're strictly validating. */ + private boolean checkFullyPopulated = false; + + /** Whether we're defaulting primitives when mapping a null value. */ + private boolean primitivesDefaultedForNullValue = false; + + /** ConversionService for binding values to bean properties. */ + private @Nullable ConversionService conversionService; + + /** Map of the fields we provide mapping for. */ + private @Nullable Map mappedFields; + + /** Set of bean properties we provide mapping for. */ + private @Nullable Set mappedProperties; + + /** + * Create a new {@code BeanPropertyRowMapper} for bean-style configuration. + * + * @see #setMappedClass + * @see #setCheckFullyPopulated + */ + public BeanPropertyRowMapper() {} + + /** + * Create a new {@code BeanPropertyRowMapper}, accepting unpopulated properties in the target bean. + * + * @param mappedClass the class that each row should be mapped to + */ + public BeanPropertyRowMapper(Class mappedClass) { + setMappedClass(mappedClass); + } + + /** + * Create a new {@code BeanPropertyRowMapper}. + * + * @param mappedClass the class that each row should be mapped to + * @param checkFullyPopulated whether we're strictly validating that all bean properties have been mapped from + * corresponding database fields + */ + public BeanPropertyRowMapper(Class mappedClass, boolean checkFullyPopulated) { + initialize(mappedClass); + this.checkFullyPopulated = checkFullyPopulated; + } + + { + DefaultConversionService conversionService = new DefaultConversionService(); + Jsr310Converters.getConvertersToRegister().forEach(conversionService::addConverter); + CassandraJsr310Converters.getConvertersToRegister().forEach(conversionService::addConverter); + this.conversionService = conversionService; + } + + /** + * Set the class that each row should be mapped to. + */ + public void setMappedClass(Class mappedClass) { + if (this.mappedClass == null) { + initialize(mappedClass); + } else { + if (this.mappedClass != mappedClass) { + throw new InvalidDataAccessApiUsageException("The mapped class can not be reassigned to map to " + mappedClass + + " since it is already providing mapping for " + this.mappedClass); + } + } + } + + /** + * Get the class that we are mapping to. + */ + @Nullable + public final Class getMappedClass() { + return this.mappedClass; + } + + /** + * Set whether we're strictly validating that all bean properties have been mapped from corresponding database fields. + *

+ * Default is {@literal false}, accepting unpopulated properties in the target bean. + */ + public void setCheckFullyPopulated(boolean checkFullyPopulated) { + this.checkFullyPopulated = checkFullyPopulated; + } + + /** + * Return whether we're strictly validating that all bean properties have been mapped from corresponding database + * fields. + */ + public boolean isCheckFullyPopulated() { + return this.checkFullyPopulated; + } + + /** + * Set whether we're defaulting Java primitives in the case of mapping a null value from corresponding database + * fields. + *

+ * Default is {@literal false}, throwing an exception when nulls are mapped to Java primitives. + */ + public void setPrimitivesDefaultedForNullValue(boolean primitivesDefaultedForNullValue) { + this.primitivesDefaultedForNullValue = primitivesDefaultedForNullValue; + } + + /** + * Return whether we're defaulting Java primitives in the case of mapping a null value from corresponding database + * fields. + */ + public boolean isPrimitivesDefaultedForNullValue() { + return this.primitivesDefaultedForNullValue; + } + + /** + * Set a {@link ConversionService} for binding Cassandra values to bean properties, or {@literal null} for none. + *

+ * Default is a {@link DefaultConversionService}. This provides support for {@code java.time} conversion and other + * special types. + * + * @see #initBeanWrapper(BeanWrapper) + */ + public void setConversionService(@Nullable ConversionService conversionService) { + this.conversionService = conversionService; + } + + /** + * Return a {@link ConversionService} for binding Cassandra values to bean properties, or {@literal null} if none. + */ + @Nullable + public ConversionService getConversionService() { + return this.conversionService; + } + + /** + * Initialize the mapping meta-data for the given class. + * + * @param mappedClass the mapped class + */ + protected void initialize(Class mappedClass) { + this.mappedClass = mappedClass; + this.mappedFields = new HashMap<>(); + this.mappedProperties = new HashSet<>(); + + for (PropertyDescriptor pd : BeanUtils.getPropertyDescriptors(mappedClass)) { + if (pd.getWriteMethod() != null) { + this.mappedFields.put(lowerCaseName(pd.getName()), pd); + String underscoredName = underscoreName(pd.getName()); + if (!lowerCaseName(pd.getName()).equals(underscoredName)) { + this.mappedFields.put(underscoredName, pd); + } + this.mappedProperties.add(pd.getName()); + } + } + } + + /** + * Convert a name in camelCase to an underscored name in lower case. Any upper case letters are converted to lower + * case with a preceding underscore. + * + * @param name the original name + * @return the converted name + * @see #lowerCaseName + */ + protected String underscoreName(String name) { + if (!StringUtils.hasLength(name)) { + return ""; + } + + StringBuilder result = new StringBuilder(); + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + if (Character.isUpperCase(c)) { + result.append('_').append(Character.toLowerCase(c)); + } else { + result.append(c); + } + } + return result.toString(); + } + + /** + * Convert the given name to lower case. By default, conversions will happen within the US locale. + * + * @param name the original name + * @return the converted name + */ + protected String lowerCaseName(String name) { + return name.toLowerCase(Locale.US); + } + + /** + * Extract the values for all columns in the current row. + *

+ * Utilizes public setters and result set meta-data. + */ + @Override + public T mapRow(Row row, int rowNumber) { + + BeanWrapperImpl bw = new BeanWrapperImpl(); + initBeanWrapper(bw); + + T mappedObject = constructMappedInstance(row, bw); + bw.setBeanInstance(mappedObject); + int columnCount = row.getColumnDefinitions().size(); + Set populatedProperties = (isCheckFullyPopulated() ? new HashSet<>() : null); + + for (int index = 0; index < columnCount; index++) { + String column = row.getColumnDefinitions().get(index).getName().toString(); + String field = lowerCaseName(StringUtils.delete(column, " ")); + PropertyDescriptor pd = (this.mappedFields != null ? this.mappedFields.get(field) : null); + if (pd != null) { + try { + Object value = getColumnValue(row, index, pd); + if (rowNumber == 0 && logger.isDebugEnabled()) { + logger.debug("Mapping column '" + column + "' to property '" + pd.getName() + "' of type '" + + ClassUtils.getQualifiedName(pd.getPropertyType()) + "'"); + } + try { + bw.setPropertyValue(pd.getName(), value); + } catch (TypeMismatchException ex) { + if (value == null && this.primitivesDefaultedForNullValue) { + if (logger.isDebugEnabled()) { + logger.debug("Intercepted TypeMismatchException for row " + rowNumber + " and column '" + column + + "' with null value when setting property '" + pd.getName() + "' of type '" + + ClassUtils.getQualifiedName(pd.getPropertyType()) + "' on object: " + mappedObject, ex); + } + } else { + throw ex; + } + } + if (populatedProperties != null) { + populatedProperties.add(pd.getName()); + } + } catch (NotWritablePropertyException ex) { + throw new DataRetrievalFailureException( + "Unable to map column '" + column + "' to property '" + pd.getName() + "'", ex); + } + } else { + // No PropertyDescriptor found + if (rowNumber == 0 && logger.isDebugEnabled()) { + logger.debug("No property found for column '" + column + "' mapped to field '" + field + "'"); + } + } + } + + if (populatedProperties != null && !populatedProperties.equals(this.mappedProperties)) { + throw new InvalidDataAccessApiUsageException("Given Row does not contain all columns " + + "necessary to populate object of " + this.mappedClass + ": " + this.mappedProperties); + } + + return mappedObject; + } + + /** + * Construct an instance of the mapped class for the current row. + * + * @param row the row to map (pre-initialized for the current row) + * @param tc a TypeConverter with this RowMapper's conversion service + * @return a corresponding instance of the mapped class + */ + protected T constructMappedInstance(Row row, TypeConverter tc) { + + Assert.state(this.mappedClass != null, "Mapped class was not specified"); + + return BeanUtils.instantiateClass(this.mappedClass); + } + + /** + * Initialize the given {@link BeanWrapper} to be used for row mapping. To be called for each row. + *

+ * The default implementation applies the configured {@link ConversionService}, if any. Can be overridden in + * subclasses. + * + * @param bw the BeanWrapper to initialize + * @see #getConversionService() + * @see BeanWrapper#setConversionService + */ + protected void initBeanWrapper(BeanWrapper bw) { + + ConversionService cs = getConversionService(); + + if (cs != null) { + bw.setConversionService(cs); + } + } + + /** + * Retrieve a Cassandra object value for the specified column. + *

+ * The default implementation delegates to {@link Row#get(int, Class)}. + * + * @param row is the row holding the data + * @param index is the column index + * @param pd the bean property that each result object is expected to match + * @return the Object value + * @see #getColumnValue(Row, int, Class) + */ + @Nullable + protected Object getColumnValue(Row row, int index, PropertyDescriptor pd) { + return row.get(index, pd.getPropertyType()); + } + + /** + * Retrieve a Cassandra object value for the specified column. + *

+ * The default implementation calls {@link Row#get(int, Class)}. Subclasses may override this to check specific value + * types upfront, or to post-process values return from {@code get}. + * + * @param row is the row holding the data. + * @param index is the column index. + * @param paramType the target parameter type. + * @return the Object value. + */ + @Nullable + protected Object getColumnValue(Row row, int index, Class paramType) { + return row.get(index, paramType); + } + + /** + * Static factory method to create a new {@code BeanPropertyRowMapper}. + * + * @param mappedClass the class that each row should be mapped to. + * @see #newInstance(Class, ConversionService) + */ + public static BeanPropertyRowMapper newInstance(Class mappedClass) { + return new BeanPropertyRowMapper<>(mappedClass); + } + + /** + * Static factory method to create a new {@code BeanPropertyRowMapper}. + * + * @param mappedClass the class that each row should be mapped to. + * @param conversionService the {@link ConversionService} for binding Cassandra values to bean properties, or + * {@literal null} for none. + * @see #newInstance(Class) + * @see #setConversionService + */ + public static BeanPropertyRowMapper newInstance(Class mappedClass, + @Nullable ConversionService conversionService) { + + BeanPropertyRowMapper rowMapper = newInstance(mappedClass); + rowMapper.setConversionService(conversionService); + return rowMapper; + } + +} diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/cql/DataClassRowMapper.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/cql/DataClassRowMapper.java new file mode 100644 index 000000000..4454fb6ba --- /dev/null +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/cql/DataClassRowMapper.java @@ -0,0 +1,116 @@ +/* + * Copyright 2020 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 + * + * https://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.cassandra.core.cql; + +import java.lang.reflect.Constructor; + +import org.springframework.beans.BeanUtils; +import org.springframework.beans.TypeConverter; +import org.springframework.core.convert.ConversionService; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +import com.datastax.oss.driver.api.core.cql.Row; + +/** + * {@link RowMapper} implementation that converts a row into a new instance of the specified mapped target class. The + * mapped target class must be a top-level class and may either expose a data class constructor with named parameters + * corresponding to column names or classic bean property setters (or even a combination of both). + *

+ * Note that this class extends {@link BeanPropertyRowMapper} and can therefore serve as a common choice for any mapped + * target class, flexibly adapting to constructor style versus setter methods in the mapped class. + * + * @author Mark Paluch + * @since 3.1 + * @param the result type + */ +public class DataClassRowMapper extends BeanPropertyRowMapper { + + private @Nullable Constructor mappedConstructor; + + private @Nullable String[] constructorParameterNames; + + private @Nullable Class[] constructorParameterTypes; + + /** + * Create a new {@code DataClassRowMapper}. + * + * @param mappedClass the class that each row should be mapped to. + */ + public DataClassRowMapper(Class mappedClass) { + super(mappedClass); + } + + @Override + protected void initialize(Class mappedClass) { + + super.initialize(mappedClass); + + this.mappedConstructor = BeanUtils.getResolvableConstructor(mappedClass); + if (this.mappedConstructor.getParameterCount() > 0) { + this.constructorParameterNames = BeanUtils.getParameterNames(this.mappedConstructor); + this.constructorParameterTypes = this.mappedConstructor.getParameterTypes(); + } + } + + @Override + protected T constructMappedInstance(Row row, TypeConverter tc) { + + Assert.state(this.mappedConstructor != null, "Mapped constructor was not initialized"); + + Object[] args; + if (this.constructorParameterNames != null && this.constructorParameterTypes != null) { + args = new Object[this.constructorParameterNames.length]; + for (int i = 0; i < args.length; i++) { + String name = underscoreName(this.constructorParameterNames[i]); + Class type = this.constructorParameterTypes[i]; + args[i] = tc.convertIfNecessary(getColumnValue(row, row.getColumnDefinitions().firstIndexOf(name), type), type); + } + } else { + args = new Object[0]; + } + + return BeanUtils.instantiateClass(this.mappedConstructor, args); + } + + /** + * Static factory method to create a new {@code DataClassRowMapper}. + * + * @param mappedClass the class that each row should be mapped to. + * @see #newInstance(Class, ConversionService) + */ + public static DataClassRowMapper newInstance(Class mappedClass) { + return new DataClassRowMapper<>(mappedClass); + } + + /** + * Static factory method to create a new {@code DataClassRowMapper}. + * + * @param mappedClass the class that each row should be mapped to. + * @param conversionService the {@link ConversionService} for binding Cassandra values to bean properties, or + * {@code null} for none. + * @see #newInstance(Class) + * @see #setConversionService + */ + public static DataClassRowMapper newInstance(Class mappedClass, + @Nullable ConversionService conversionService) { + + DataClassRowMapper rowMapper = newInstance(mappedClass); + rowMapper.setConversionService(conversionService); + return rowMapper; + } + +} diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/cql/session/init/KeyspacePopulator.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/cql/session/init/KeyspacePopulator.java index 0da07c326..4aa8865af 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/cql/session/init/KeyspacePopulator.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/cql/session/init/KeyspacePopulator.java @@ -29,7 +29,7 @@ import com.datastax.oss.driver.api.core.CqlSession; public interface KeyspacePopulator { /** - * Populate, initialize, or clean up the database using the provided JDBC connection. + * Populate, initialize, or clean up the database using the provided CqlSession connection. *

* Concrete implementations may throw a {@link RuntimeException} if an error is encountered but are * strongly encouraged to throw a specific {@link ScriptException} instead. For example, Spring's diff --git a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/cql/session/init/ScriptStatementFailedException.java b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/cql/session/init/ScriptStatementFailedException.java index ffa6682a9..197cf6a99 100644 --- a/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/cql/session/init/ScriptStatementFailedException.java +++ b/spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/cql/session/init/ScriptStatementFailedException.java @@ -18,7 +18,7 @@ package org.springframework.data.cassandra.core.cql.session.init; import org.springframework.core.io.support.EncodedResource; /** - * Thrown by {@link ScriptUtils} if a statement in an SQL script failed when executing it against the target database. + * Thrown by {@link ScriptUtils} if a statement in an CQL script failed when executing it against the target database. * * @author Mark Paluch * @since 3.0 diff --git a/spring-data-cassandra/src/main/kotlin/org/springframework/data/cassandra/core/cql/DataClassRowMapperExtensions.kt b/spring-data-cassandra/src/main/kotlin/org/springframework/data/cassandra/core/cql/DataClassRowMapperExtensions.kt new file mode 100644 index 000000000..e69f11c6c --- /dev/null +++ b/spring-data-cassandra/src/main/kotlin/org/springframework/data/cassandra/core/cql/DataClassRowMapperExtensions.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2020 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 + * + * https://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.cassandra.core.cql + +/** + * Extensions for [DataClassRowMapper]. + * + * @author Mark Paluch + * @since 3.1 + */ + +/** + * Extension for [DataClassRowMapper] leveraging reified type parameters. + */ +inline fun DataClassRowMapper(): DataClassRowMapper = + DataClassRowMapper(T::class.java) diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/cql/BeanPropertyRowMapperUnitTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/cql/BeanPropertyRowMapperUnitTests.java new file mode 100644 index 000000000..4690619d0 --- /dev/null +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/cql/BeanPropertyRowMapperUnitTests.java @@ -0,0 +1,131 @@ +/* + * Copyright 2020 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 + * + * https://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.cassandra.core.cql; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import org.springframework.dao.InvalidDataAccessApiUsageException; + +import com.datastax.oss.driver.api.core.CqlIdentifier; +import com.datastax.oss.driver.api.core.cql.ColumnDefinition; +import com.datastax.oss.driver.api.core.cql.ColumnDefinitions; +import com.datastax.oss.driver.api.core.cql.Row; + +/** + * Unit tests for {@link BeanPropertyRowMapper}. + * + * @author Mark Paluch + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class BeanPropertyRowMapperUnitTests { + + @Mock Row row; + + @Test // DATACASS-810 + void createBeanFromRow() { + + ColumnDefinitions definitions = forColumns("firstname", "age"); + when(row.getColumnDefinitions()).thenReturn(definitions); + when(row.get(0, String.class)).thenReturn("Walter"); + when(row.get(1, int.class)).thenReturn(42); + + BeanPropertyRowMapper rowMapper = new BeanPropertyRowMapper<>(Person.class); + + Person person = rowMapper.mapRow(row, 0); + + assertThat(person.firstname).isEqualTo("Walter"); + assertThat(person.age).isEqualTo(42); + } + + @Test // DATACASS-810 + void createBeanFromRowWithNullDefault() { + + ColumnDefinitions definitions = forColumns("firstname", "age"); + when(row.getColumnDefinitions()).thenReturn(definitions); + when(row.get(0, String.class)).thenReturn("Walter"); + + BeanPropertyRowMapper rowMapper = new BeanPropertyRowMapper<>(Person.class); + rowMapper.setPrimitivesDefaultedForNullValue(true); + + Person person = rowMapper.mapRow(row, 0); + + assertThat(person.firstname).isEqualTo("Walter"); + assertThat(person.age).isEqualTo(0); + } + + @Test // DATACASS-810 + void shouldRefusePartiallyPopulatedResult() { + + ColumnDefinitions definitions = forColumns("age"); + when(row.getColumnDefinitions()).thenReturn(definitions); + when(row.get(0, int.class)).thenReturn(42); + + BeanPropertyRowMapper rowMapper = new BeanPropertyRowMapper<>(Person.class); + rowMapper.setCheckFullyPopulated(true); + + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class).isThrownBy(() -> rowMapper.mapRow(row, 0)); + } + + static class Person { + + String firstname; + int age; + + public String getFirstname() { + return firstname; + } + + public void setFirstname(String firstname) { + this.firstname = firstname; + } + + public int getAge() { + return age; + } + + public void setAge(int age) { + this.age = age; + } + } + + private static ColumnDefinitions forColumns(String... columns) { + + ColumnDefinitions definitions = mock(ColumnDefinitions.class); + + int index = 0; + for (String column : columns) { + + ColumnDefinition columnDefinition = mock(ColumnDefinition.class); + when(columnDefinition.getName()).thenReturn(CqlIdentifier.fromInternal(column)); + + when(definitions.get(index++)).thenReturn(columnDefinition); + } + + when(definitions.size()).thenReturn(index); + + return definitions; + } + +} diff --git a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/cql/CqlTemplateIntegrationTests.java b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/cql/CqlTemplateIntegrationTests.java index 55f964fa4..4d79f7404 100644 --- a/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/cql/CqlTemplateIntegrationTests.java +++ b/spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/cql/CqlTemplateIntegrationTests.java @@ -33,6 +33,7 @@ import org.springframework.data.util.Version; import com.datastax.oss.driver.api.core.CqlIdentifier; import com.datastax.oss.driver.api.core.cql.SimpleStatement; +import com.datastax.oss.driver.api.core.data.UdtValue; /** * Integration tests for {@link CqlTemplate}. @@ -41,6 +42,7 @@ import com.datastax.oss.driver.api.core.cql.SimpleStatement; * @author Tomasz Lelek */ class CqlTemplateIntegrationTests extends AbstractKeyspaceCreatingIntegrationTests { + private static final Version CASSANDRA_4 = Version.parse("4.0"); private static final AtomicBoolean initialized = new AtomicBoolean(); private CqlTemplate template; @@ -50,11 +52,17 @@ class CqlTemplateIntegrationTests extends AbstractKeyspaceCreatingIntegrationTes void before() { if (initialized.compareAndSet(false, true)) { + session.execute("CREATE TYPE IF NOT EXISTS cql_template_user (testname text, happy boolean);"); + session + .execute("CREATE TABLE IF NOT EXISTS cql_template_tests (id text PRIMARY KEY, thetest cql_template_user);"); session.execute("CREATE TABLE IF NOT EXISTS user (id text PRIMARY KEY, username text);"); } session.execute("TRUNCATE user;"); + session.execute("TRUNCATE cql_template_tests;"); session.execute("INSERT INTO user (id, username) VALUES ('WHITE', 'Walter');"); + session.execute( + "INSERT INTO cql_template_tests (id, thetest) VALUES ('shouldApplyBeanPropertyRowMapper', {testname: 'shouldApplyBeanPropertyRowMapper', happy: true});"); template = new CqlTemplate(); template.setSession(getSession()); @@ -197,6 +205,38 @@ class CqlTemplateIntegrationTests extends AbstractKeyspaceCreatingIntegrationTes assertThatThrownBy(() -> template.queryForObject("SELECT id FROM user;", String.class)) .isInstanceOf(CassandraInvalidQueryException.class) .hasMessageContaining("Keyspace 'non_existing' does not exist"); + } + @Test // DATACASS-810 + void shouldApplyBeanPropertyRowMapper() { + + CqlTemplateTest result = template.queryForObject("SELECT * FROM cql_template_tests", + new BeanPropertyRowMapper<>(CqlTemplateTest.class)); + + assertThat(result.getId()).isEqualTo("shouldApplyBeanPropertyRowMapper"); + assertThat(result.getThetest().getFormattedContents()).contains("testname:'shouldApplyBeanPropertyRowMapper'") + .contains("happy:true"); + } + + static class CqlTemplateTest { + + String id; + UdtValue thetest; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public UdtValue getThetest() { + return thetest; + } + + public void setThetest(UdtValue thetest) { + this.thetest = thetest; + } } } diff --git a/spring-data-cassandra/src/test/kotlin/org/springframework/data/cassandra/core/cql/DataClassRowMapperUnitTests.kt b/spring-data-cassandra/src/test/kotlin/org/springframework/data/cassandra/core/cql/DataClassRowMapperUnitTests.kt new file mode 100644 index 000000000..b35266a47 --- /dev/null +++ b/spring-data-cassandra/src/test/kotlin/org/springframework/data/cassandra/core/cql/DataClassRowMapperUnitTests.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2020 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 + * + * https://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.cassandra.core.cql + +import com.datastax.oss.driver.api.core.CqlIdentifier +import com.datastax.oss.driver.api.core.cql.ColumnDefinition +import com.datastax.oss.driver.api.core.cql.ColumnDefinitions +import com.datastax.oss.driver.api.core.cql.Row +import io.mockk.every +import io.mockk.mockk +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.mockito.Mockito + +/** + * Unit tests for [DataClassRowMapper]. + * + * @author Mark Paluch + */ +class DataClassRowMapperUnitTests { + + val row = mockk(relaxed = true) + + @Test // DATACASS-810 + fun createBeanFromRow() { + + val definitions = forColumns("firstname", "age") + + every { row.columnDefinitions } returns definitions + every { row.get(0, String::class.java) } returns "Walter" + every { row.get(1, Int::class.javaPrimitiveType) } returns 42 + + val rowMapper = DataClassRowMapper() + val person = rowMapper.mapRow(row, 0) + + assertThat(person.firstname).isEqualTo("Walter") + assertThat(person.age).isEqualTo(42) + } + + data class Person(val firstname: String, val age: Int) + + private fun forColumns(vararg columns: String): ColumnDefinitions { + + val definitions = Mockito.mock(ColumnDefinitions::class.java) + var index = 0 + for (column in columns) { + val columnDefinition = Mockito.mock(ColumnDefinition::class.java) + val columnIndex = index++ + Mockito.`when`(columnDefinition.name).thenReturn(CqlIdentifier.fromInternal(column)) + Mockito.`when`(definitions[columnIndex]).thenReturn(columnDefinition) + Mockito.`when`(definitions.firstIndexOf(column)).thenReturn(columnIndex) + } + Mockito.`when`(definitions.size()).thenReturn(index) + + return definitions + } +} diff --git a/src/main/asciidoc/new-features.adoc b/src/main/asciidoc/new-features.adoc index 66cc8c9ea..4cf175db0 100644 --- a/src/main/asciidoc/new-features.adoc +++ b/src/main/asciidoc/new-features.adoc @@ -10,6 +10,7 @@ This chapter summarizes changes and new features for each release. * Reactive SpEL support in `@Query` query methods. * Configuration of the keyspace per `Statement` through `CqlTemplate` and `QueryOptions`. * Revised `CqlOperations` with new `queryForStream(…)` methods returning a `Stream` with transparent pagination. +* `DataClassRowMapper` to map Cassandra results to data classes via constructor creation/bean properties. [[new-features.3-0-0]] == What's new in Spring Data for Apache Cassandra 3.0