Skip to content

Commit 392cf77

Browse files
committed
DATACASS-810 - Add DataClassRowMapper.
DataClassRowMapper can map Cassandra results onto a class following the data class pattern (using constructor properties) or by setting bean properties. data class Person(val firstname: String, val age: Int) cqlTemplate.query("SELECT * FROM person", DataClassRowMapper<Person>())
1 parent 9728df0 commit 392cf77

File tree

9 files changed

+811
-2
lines changed

9 files changed

+811
-2
lines changed

spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/cql/BeanPropertyRowMapper.java

Lines changed: 422 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/*
2+
* Copyright 2020 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.data.cassandra.core.cql;
17+
18+
import java.lang.reflect.Constructor;
19+
20+
import org.springframework.beans.BeanUtils;
21+
import org.springframework.beans.TypeConverter;
22+
import org.springframework.core.convert.ConversionService;
23+
import org.springframework.lang.Nullable;
24+
import org.springframework.util.Assert;
25+
26+
import com.datastax.oss.driver.api.core.cql.Row;
27+
28+
/**
29+
* {@link RowMapper} implementation that converts a row into a new instance of the specified mapped target class. The
30+
* mapped target class must be a top-level class and may either expose a data class constructor with named parameters
31+
* corresponding to column names or classic bean property setters (or even a combination of both).
32+
* <p>
33+
* Note that this class extends {@link BeanPropertyRowMapper} and can therefore serve as a common choice for any mapped
34+
* target class, flexibly adapting to constructor style versus setter methods in the mapped class.
35+
*
36+
* @author Mark Paluch
37+
* @since 3.1
38+
* @param <T> the result type
39+
*/
40+
public class DataClassRowMapper<T> extends BeanPropertyRowMapper<T> {
41+
42+
private @Nullable Constructor<T> mappedConstructor;
43+
44+
private @Nullable String[] constructorParameterNames;
45+
46+
private @Nullable Class<?>[] constructorParameterTypes;
47+
48+
/**
49+
* Create a new {@code DataClassRowMapper}.
50+
*
51+
* @param mappedClass the class that each row should be mapped to.
52+
*/
53+
public DataClassRowMapper(Class<T> mappedClass) {
54+
super(mappedClass);
55+
}
56+
57+
@Override
58+
protected void initialize(Class<T> mappedClass) {
59+
60+
super.initialize(mappedClass);
61+
62+
this.mappedConstructor = BeanUtils.getResolvableConstructor(mappedClass);
63+
if (this.mappedConstructor.getParameterCount() > 0) {
64+
this.constructorParameterNames = BeanUtils.getParameterNames(this.mappedConstructor);
65+
this.constructorParameterTypes = this.mappedConstructor.getParameterTypes();
66+
}
67+
}
68+
69+
@Override
70+
protected T constructMappedInstance(Row row, TypeConverter tc) {
71+
72+
Assert.state(this.mappedConstructor != null, "Mapped constructor was not initialized");
73+
74+
Object[] args;
75+
if (this.constructorParameterNames != null && this.constructorParameterTypes != null) {
76+
args = new Object[this.constructorParameterNames.length];
77+
for (int i = 0; i < args.length; i++) {
78+
String name = underscoreName(this.constructorParameterNames[i]);
79+
Class<?> type = this.constructorParameterTypes[i];
80+
args[i] = tc.convertIfNecessary(getColumnValue(row, row.getColumnDefinitions().firstIndexOf(name), type), type);
81+
}
82+
} else {
83+
args = new Object[0];
84+
}
85+
86+
return BeanUtils.instantiateClass(this.mappedConstructor, args);
87+
}
88+
89+
/**
90+
* Static factory method to create a new {@code DataClassRowMapper}.
91+
*
92+
* @param mappedClass the class that each row should be mapped to.
93+
* @see #newInstance(Class, ConversionService)
94+
*/
95+
public static <T> DataClassRowMapper<T> newInstance(Class<T> mappedClass) {
96+
return new DataClassRowMapper<>(mappedClass);
97+
}
98+
99+
/**
100+
* Static factory method to create a new {@code DataClassRowMapper}.
101+
*
102+
* @param mappedClass the class that each row should be mapped to.
103+
* @param conversionService the {@link ConversionService} for binding Cassandra values to bean properties, or
104+
* {@code null} for none.
105+
* @see #newInstance(Class)
106+
* @see #setConversionService
107+
*/
108+
public static <T> DataClassRowMapper<T> newInstance(Class<T> mappedClass,
109+
@Nullable ConversionService conversionService) {
110+
111+
DataClassRowMapper<T> rowMapper = newInstance(mappedClass);
112+
rowMapper.setConversionService(conversionService);
113+
return rowMapper;
114+
}
115+
116+
}

spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/cql/session/init/KeyspacePopulator.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
public interface KeyspacePopulator {
3030

3131
/**
32-
* Populate, initialize, or clean up the database using the provided JDBC connection.
32+
* Populate, initialize, or clean up the database using the provided CqlSession connection.
3333
* <p>
3434
* Concrete implementations <em>may</em> throw a {@link RuntimeException} if an error is encountered but are
3535
* <em>strongly encouraged</em> to throw a specific {@link ScriptException} instead. For example, Spring's

spring-data-cassandra/src/main/java/org/springframework/data/cassandra/core/cql/session/init/ScriptStatementFailedException.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
import org.springframework.core.io.support.EncodedResource;
1919

2020
/**
21-
* Thrown by {@link ScriptUtils} if a statement in an SQL script failed when executing it against the target database.
21+
* Thrown by {@link ScriptUtils} if a statement in an CQL script failed when executing it against the target database.
2222
*
2323
* @author Mark Paluch
2424
* @since 3.0
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
* Copyright 2020 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.data.cassandra.core.cql
17+
18+
/**
19+
* Extensions for [DataClassRowMapper].
20+
*
21+
* @author Mark Paluch
22+
* @since 3.1
23+
*/
24+
25+
/**
26+
* Extension for [DataClassRowMapper] leveraging reified type parameters.
27+
*/
28+
inline fun <reified T : Any> DataClassRowMapper(): DataClassRowMapper<T> =
29+
DataClassRowMapper(T::class.java)
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/*
2+
* Copyright 2020 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.data.cassandra.core.cql;
17+
18+
import static org.assertj.core.api.Assertions.*;
19+
import static org.mockito.Mockito.*;
20+
21+
import org.junit.jupiter.api.Test;
22+
import org.junit.jupiter.api.extension.ExtendWith;
23+
import org.mockito.Mock;
24+
import org.mockito.junit.jupiter.MockitoExtension;
25+
import org.mockito.junit.jupiter.MockitoSettings;
26+
import org.mockito.quality.Strictness;
27+
28+
import org.springframework.dao.InvalidDataAccessApiUsageException;
29+
30+
import com.datastax.oss.driver.api.core.CqlIdentifier;
31+
import com.datastax.oss.driver.api.core.cql.ColumnDefinition;
32+
import com.datastax.oss.driver.api.core.cql.ColumnDefinitions;
33+
import com.datastax.oss.driver.api.core.cql.Row;
34+
35+
/**
36+
* Unit tests for {@link BeanPropertyRowMapper}.
37+
*
38+
* @author Mark Paluch
39+
*/
40+
@ExtendWith(MockitoExtension.class)
41+
@MockitoSettings(strictness = Strictness.LENIENT)
42+
class BeanPropertyRowMapperUnitTests {
43+
44+
@Mock Row row;
45+
46+
@Test // DATACASS-810
47+
void createBeanFromRow() {
48+
49+
ColumnDefinitions definitions = forColumns("firstname", "age");
50+
when(row.getColumnDefinitions()).thenReturn(definitions);
51+
when(row.get(0, String.class)).thenReturn("Walter");
52+
when(row.get(1, int.class)).thenReturn(42);
53+
54+
BeanPropertyRowMapper<Person> rowMapper = new BeanPropertyRowMapper<>(Person.class);
55+
56+
Person person = rowMapper.mapRow(row, 0);
57+
58+
assertThat(person.firstname).isEqualTo("Walter");
59+
assertThat(person.age).isEqualTo(42);
60+
}
61+
62+
@Test // DATACASS-810
63+
void createBeanFromRowWithNullDefault() {
64+
65+
ColumnDefinitions definitions = forColumns("firstname", "age");
66+
when(row.getColumnDefinitions()).thenReturn(definitions);
67+
when(row.get(0, String.class)).thenReturn("Walter");
68+
69+
BeanPropertyRowMapper<Person> rowMapper = new BeanPropertyRowMapper<>(Person.class);
70+
rowMapper.setPrimitivesDefaultedForNullValue(true);
71+
72+
Person person = rowMapper.mapRow(row, 0);
73+
74+
assertThat(person.firstname).isEqualTo("Walter");
75+
assertThat(person.age).isEqualTo(0);
76+
}
77+
78+
@Test // DATACASS-810
79+
void shouldRefusePartiallyPopulatedResult() {
80+
81+
ColumnDefinitions definitions = forColumns("age");
82+
when(row.getColumnDefinitions()).thenReturn(definitions);
83+
when(row.get(0, int.class)).thenReturn(42);
84+
85+
BeanPropertyRowMapper<Person> rowMapper = new BeanPropertyRowMapper<>(Person.class);
86+
rowMapper.setCheckFullyPopulated(true);
87+
88+
assertThatExceptionOfType(InvalidDataAccessApiUsageException.class).isThrownBy(() -> rowMapper.mapRow(row, 0));
89+
}
90+
91+
static class Person {
92+
93+
String firstname;
94+
int age;
95+
96+
public String getFirstname() {
97+
return firstname;
98+
}
99+
100+
public void setFirstname(String firstname) {
101+
this.firstname = firstname;
102+
}
103+
104+
public int getAge() {
105+
return age;
106+
}
107+
108+
public void setAge(int age) {
109+
this.age = age;
110+
}
111+
}
112+
113+
private static ColumnDefinitions forColumns(String... columns) {
114+
115+
ColumnDefinitions definitions = mock(ColumnDefinitions.class);
116+
117+
int index = 0;
118+
for (String column : columns) {
119+
120+
ColumnDefinition columnDefinition = mock(ColumnDefinition.class);
121+
when(columnDefinition.getName()).thenReturn(CqlIdentifier.fromInternal(column));
122+
123+
when(definitions.get(index++)).thenReturn(columnDefinition);
124+
}
125+
126+
when(definitions.size()).thenReturn(index);
127+
128+
return definitions;
129+
}
130+
131+
}

spring-data-cassandra/src/test/java/org/springframework/data/cassandra/core/cql/CqlTemplateIntegrationTests.java

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333

3434
import com.datastax.oss.driver.api.core.CqlIdentifier;
3535
import com.datastax.oss.driver.api.core.cql.SimpleStatement;
36+
import com.datastax.oss.driver.api.core.data.UdtValue;
3637

3738
/**
3839
* Integration tests for {@link CqlTemplate}.
@@ -41,6 +42,7 @@
4142
* @author Tomasz Lelek
4243
*/
4344
class CqlTemplateIntegrationTests extends AbstractKeyspaceCreatingIntegrationTests {
45+
4446
private static final Version CASSANDRA_4 = Version.parse("4.0");
4547
private static final AtomicBoolean initialized = new AtomicBoolean();
4648
private CqlTemplate template;
@@ -50,11 +52,17 @@ class CqlTemplateIntegrationTests extends AbstractKeyspaceCreatingIntegrationTes
5052
void before() {
5153

5254
if (initialized.compareAndSet(false, true)) {
55+
session.execute("CREATE TYPE IF NOT EXISTS cql_template_user (testname text, happy boolean);");
56+
session
57+
.execute("CREATE TABLE IF NOT EXISTS cql_template_tests (id text PRIMARY KEY, thetest cql_template_user);");
5358
session.execute("CREATE TABLE IF NOT EXISTS user (id text PRIMARY KEY, username text);");
5459
}
5560

5661
session.execute("TRUNCATE user;");
62+
session.execute("TRUNCATE cql_template_tests;");
5763
session.execute("INSERT INTO user (id, username) VALUES ('WHITE', 'Walter');");
64+
session.execute(
65+
"INSERT INTO cql_template_tests (id, thetest) VALUES ('shouldApplyBeanPropertyRowMapper', {testname: 'shouldApplyBeanPropertyRowMapper', happy: true});");
5866

5967
template = new CqlTemplate();
6068
template.setSession(getSession());
@@ -197,6 +205,38 @@ void selectByQueryWithNonExistingKeyspaceShouldThrowThatKeyspaceDoesNotExists()
197205
assertThatThrownBy(() -> template.queryForObject("SELECT id FROM user;", String.class))
198206
.isInstanceOf(CassandraInvalidQueryException.class)
199207
.hasMessageContaining("Keyspace 'non_existing' does not exist");
208+
}
209+
210+
@Test // DATACASS-810
211+
void shouldApplyBeanPropertyRowMapper() {
212+
213+
CqlTemplateTest result = template.queryForObject("SELECT * FROM cql_template_tests",
214+
new BeanPropertyRowMapper<>(CqlTemplateTest.class));
215+
216+
assertThat(result.getId()).isEqualTo("shouldApplyBeanPropertyRowMapper");
217+
assertThat(result.getThetest().getFormattedContents()).contains("testname:'shouldApplyBeanPropertyRowMapper'")
218+
.contains("happy:true");
219+
}
220+
221+
static class CqlTemplateTest {
222+
223+
String id;
224+
UdtValue thetest;
200225

226+
public String getId() {
227+
return id;
228+
}
229+
230+
public void setId(String id) {
231+
this.id = id;
232+
}
233+
234+
public UdtValue getThetest() {
235+
return thetest;
236+
}
237+
238+
public void setThetest(UdtValue thetest) {
239+
this.thetest = thetest;
240+
}
201241
}
202242
}

0 commit comments

Comments
 (0)