INT-4566: R2DBC Outbound Channel Adapter

JIRA: https://jira.spring.io/browse/INT-4566

* Fixed review comments
* added `DELETE` and `Criteria` implementation for outbound channel adapter
* Clean up code style
* Add initial docs
This commit is contained in:
fnu, rohan
2020-06-08 11:40:13 -04:00
committed by Artem Bilan
parent b860a2e818
commit dfd914254b
8 changed files with 725 additions and 0 deletions

View File

@@ -105,6 +105,7 @@ ext {
springWsVersion = '3.0.9.RELEASE'
tomcatVersion = "9.0.36"
xstreamVersion = '1.4.12'
r2dbch2Version='0.8.4.RELEASE'
javaProjects = subprojects - project(':spring-integration-bom')
}
@@ -637,6 +638,17 @@ project('spring-integration-mongodb') {
}
}
project('spring-integration-r2dbc') {
description = 'Spring Integration R2DBC Support'
dependencies {
api project(':spring-integration-core')
api ('org.springframework.data:spring-data-r2dbc') {
exclude group: 'org.springframework'
}
testImplementation "io.r2dbc:r2dbc-h2:$r2dbch2Version"
}
}
project('spring-integration-mqtt') {
description = 'Spring Integration MQTT Support'
dependencies {

View File

@@ -0,0 +1,251 @@
/*
* 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.integration.r2dbc.outbound;
import java.util.HashMap;
import java.util.Map;
import org.springframework.data.r2dbc.core.DatabaseClient;
import org.springframework.data.r2dbc.core.R2dbcEntityOperations;
import org.springframework.data.relational.core.query.Criteria;
import org.springframework.data.relational.core.query.Update;
import org.springframework.data.relational.core.sql.SqlIdentifier;
import org.springframework.expression.Expression;
import org.springframework.expression.TypeLocator;
import org.springframework.expression.common.LiteralExpression;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.expression.spel.support.StandardTypeLocator;
import org.springframework.integration.expression.ExpressionUtils;
import org.springframework.integration.expression.ValueExpression;
import org.springframework.integration.handler.AbstractReactiveMessageHandler;
import org.springframework.lang.Nullable;
import org.springframework.messaging.Message;
import org.springframework.util.Assert;
import reactor.core.publisher.Mono;
/**
* Implementation of {@link org.springframework.messaging.ReactiveMessageHandler} which writes
* Message payload into a Relational Database, using reactive r2dbc support.
*
* @author Rohan Mukesh
* @author Artem Bilan
*
* @since 5.4
*/
public class R2dbcMessageHandler extends AbstractReactiveMessageHandler {
private final R2dbcEntityOperations r2dbcEntityOperations;
private StandardEvaluationContext evaluationContext;
private Expression queryTypeExpression = new ValueExpression<>(Type.INSERT);
@Nullable
private Expression tableNameExpression;
@Nullable
private Expression valuesExpression;
@Nullable
private Expression criteriaExpression;
private volatile boolean initialized = false;
/**
* Construct this instance using a fully created and initialized instance of provided
* {@link R2dbcEntityOperations}
* @param r2dbcEntityOperations The R2dbcEntityOperations implementation.
*/
public R2dbcMessageHandler(R2dbcEntityOperations r2dbcEntityOperations) {
Assert.notNull(r2dbcEntityOperations, "'r2dbcEntityOperations' must not be null");
this.r2dbcEntityOperations = r2dbcEntityOperations;
}
public void setQueryType(R2dbcMessageHandler.Type type) {
setQueryTypeExpression(new ValueExpression<>(type));
}
public void setQueryTypeExpression(Expression queryTypeExpression) {
Assert.notNull(queryTypeExpression, "'queryTypeExpression' must not be null");
this.queryTypeExpression = queryTypeExpression;
}
public void setTableName(String tableName) {
setTableNameExpression(new LiteralExpression(tableName));
}
public void setTableNameExpression(Expression tableNameExpression) {
this.tableNameExpression = tableNameExpression;
}
public void setValuesExpression(Expression valuesExpression) {
this.valuesExpression = valuesExpression;
}
public void setCriteriaExpression(Expression criteriaExpression) {
this.criteriaExpression = criteriaExpression;
}
@Override
public String getComponentType() {
return "r2dbc:reactive-outbound-channel-adapter";
}
@Override
protected void onInit() {
super.onInit();
this.evaluationContext = ExpressionUtils.createStandardEvaluationContext(getBeanFactory());
TypeLocator typeLocator = this.evaluationContext.getTypeLocator();
if (typeLocator instanceof StandardTypeLocator) {
//Register R2dbc criteria API package so FQCN can be avoided in query-expression.
((StandardTypeLocator) typeLocator).registerImport("org.springframework.data.relational.core.query");
}
this.initialized = true;
}
@Override
protected Mono<Void> handleMessageInternal(Message<?> message) {
Assert.isTrue(this.initialized, "The instance is not yet initialized. Invoke its afterPropertiesSet() method");
return Mono.fromSupplier(() -> this.queryTypeExpression.getValue(this.evaluationContext, message, Type.class))
.flatMap(mode -> {
switch (mode) {
case INSERT:
return handleInsert(message);
case UPDATE:
return handleUpdate(message);
case DELETE:
return handleDelete(message);
default:
return Mono.error(new IllegalArgumentException());
}
}).then();
}
private Mono<Void> handleDelete(Message<?> message) {
if (this.tableNameExpression != null) {
String tableName = evaluateTableNameExpression(message);
Criteria criteria = evaluateCriteriaExpression(message);
DatabaseClient.DeleteMatchingSpec deleteSpec =
this.r2dbcEntityOperations.getDatabaseClient()
.delete()
.from(tableName);
return deleteSpec.matching(criteria)
.then();
}
else {
return this.r2dbcEntityOperations.delete(message.getPayload())
.then();
}
}
private Mono<Void> handleUpdate(Message<?> message) {
if (this.tableNameExpression != null) {
String tableName = evaluateTableNameExpression(message);
Map<String, Object> values = evaluateValuesExpression(message);
Map<SqlIdentifier, Object> updateMap = transformIntoSqlIdentifierMap(values);
Criteria criteria = evaluateCriteriaExpression(message);
DatabaseClient.GenericUpdateSpec updateSpec =
this.r2dbcEntityOperations.getDatabaseClient().update()
.table(tableName);
return updateSpec.using(Update.from(updateMap))
.matching(criteria)
.then();
}
else {
return this.r2dbcEntityOperations.update(message.getPayload())
.then();
}
}
private Map<SqlIdentifier, Object> transformIntoSqlIdentifierMap(Map<String, Object> values) {
Map<SqlIdentifier, Object> sqlIdentifierObjectMap = new HashMap<>();
values.forEach((k, v) -> sqlIdentifierObjectMap.put(SqlIdentifier.unquoted(k), v));
return sqlIdentifierObjectMap;
}
private Mono<Void> handleInsert(Message<?> message) {
if (this.tableNameExpression != null) {
String tableName = evaluateTableNameExpression(message);
Map<String, Object> values = evaluateValuesExpression(message);
DatabaseClient.GenericInsertSpec<Map<String, Object>> insertSpec =
this.r2dbcEntityOperations.getDatabaseClient()
.insert()
.into(tableName);
for (Map.Entry<String, Object> entry : values.entrySet()) {
insertSpec = insertSpec.value(entry.getKey(), entry.getValue());
}
return insertSpec.then();
}
else {
return this.r2dbcEntityOperations.insert(message.getPayload())
.then();
}
}
private String evaluateTableNameExpression(Message<?> message) {
String tableName = this.tableNameExpression.getValue(this.evaluationContext, message, String.class);
Assert.notNull(tableName, "'tableNameExpression' must not evaluate to null");
return tableName;
}
@SuppressWarnings("unchecked")
private Map<String, Object> evaluateValuesExpression(Message<?> message) {
Map<String, Object> fieldValues =
(Map<String, Object>) this.valuesExpression.getValue(this.evaluationContext, message, Map.class);
Assert.notNull(fieldValues, "'valuesExpression' must not evaluate to null");
return fieldValues;
}
private Criteria evaluateCriteriaExpression(Message<?> message) {
Criteria criteria =
this.criteriaExpression.getValue(this.evaluationContext, message, Criteria.class);
Assert.notNull(criteria, "'criteriaExpression' must not evaluate to null");
return criteria;
}
/**
* /**
* The mode for the {@link R2dbcMessageHandler}.
*/
public enum Type {
/**
* Set a {@link R2dbcMessageHandler} into an {@code insert} mode.
*/
INSERT,
/**
* Set a {@link R2dbcMessageHandler} into an {@code update} mode.
*/
UPDATE,
/**
* Set a {@link R2dbcMessageHandler} into a {@code delete} mode.
*/
DELETE,
}
}

View File

@@ -0,0 +1,66 @@
/*
* 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.integration.r2dbc.outbound;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Table;
/**
* @author Rohan Mukesh
*
* @since 5.4
*/
@Table
class Person {
@Id
Integer id;
String name;
Integer age;
public void setId(Integer id) {
this.id = id;
}
public void setName(String name) {
this.name = name;
}
public void setAge(Integer age) {
this.age = age;
}
Person(String name, Integer age) {
this.name = name;
this.age = age;
}
public Integer getId() {
return this.id;
}
public String getName() {
return this.name;
}
public Integer getAge() {
return this.age;
}
}

View File

@@ -0,0 +1,354 @@
/*
* 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.integration.r2dbc.outbound;
import static org.mockito.Mockito.mock;
import java.time.Duration;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.junit.Assert;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Answers;
import org.springframework.beans.factory.BeanFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.FilterType;
import org.springframework.data.r2dbc.config.AbstractR2dbcConfiguration;
import org.springframework.data.r2dbc.core.DatabaseClient;
import org.springframework.data.r2dbc.core.R2dbcEntityTemplate;
import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories;
import org.springframework.data.relational.core.query.Criteria;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import org.springframework.integration.expression.FunctionExpression;
import org.springframework.integration.support.MessageBuilder;
import org.springframework.messaging.Message;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import io.r2dbc.h2.H2ConnectionConfiguration;
import io.r2dbc.h2.H2ConnectionFactory;
import io.r2dbc.spi.ConnectionFactory;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Hooks;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
/**
* @author Rohan Mukesh
*
* @since 5.4
*/
@SpringJUnitConfig
public class R2DbcMessageHandlerTests {
@Autowired
DatabaseClient client;
@Autowired
H2ConnectionFactory factory;
R2dbcEntityTemplate entityTemplate;
@Autowired
PersonRepository personRepository;
@Configuration
@EnableR2dbcRepositories(considerNestedRepositories = true,
includeFilters = @ComponentScan.Filter(classes = PersonRepository.class, type = FilterType.ASSIGNABLE_TYPE))
static class IntegrationTestConfiguration extends AbstractR2dbcConfiguration {
@Bean
@Override
public ConnectionFactory connectionFactory() {
return createConnectionFactory();
}
}
public static ConnectionFactory createConnectionFactory() {
return new H2ConnectionFactory(H2ConnectionConfiguration.builder()
.inMemory("r2dbc")
.username("sa")
.password("")
.option("DB_CLOSE_DELAY=-1").build());
}
@BeforeEach
public void setup() {
entityTemplate = new R2dbcEntityTemplate(client);
Hooks.onOperatorDebug();
List<String> statements = Arrays.asList(
"DROP TABLE IF EXISTS person;",
"CREATE table person (id INT AUTO_INCREMENT NOT NULL, name VARCHAR2, age INT NOT NULL);");
statements.forEach(it -> client.execute(it)
.fetch()
.rowsUpdated()
.as(StepVerifier::create)
.expectNextCount(1)
.verifyComplete());
}
@Test
public void validateMessageHandlingWithDefaultInsertCollection() {
R2dbcMessageHandler handler = new R2dbcMessageHandler(this.entityTemplate);
handler.setBeanFactory(mock(BeanFactory.class));
handler.setApplicationContext(mock(ApplicationContext.class, Answers.RETURNS_MOCKS));
handler.afterPropertiesSet();
Message<Person> message = MessageBuilder.withPayload(this.createPerson("Bob", 35)).build();
waitFor(handler.handleMessage(message));
personRepository.findAll()
.as(StepVerifier::create)
.expectNextCount(1)
.verifyComplete();
}
@Test
public void validateMessageHandlingWithInsertQueryCollection() {
R2dbcMessageHandler handler = new R2dbcMessageHandler(this.entityTemplate);
handler.setBeanFactory(mock(BeanFactory.class));
handler.setApplicationContext(mock(ApplicationContext.class, Answers.RETURNS_MOCKS));
handler.setValuesExpression(new FunctionExpression<Message<?>>(Message::getPayload));
handler.setQueryType(R2dbcMessageHandler.Type.INSERT);
handler.setTableName("person");
handler.afterPropertiesSet();
Map<String, Object> payload = new HashMap<>();
payload.put("name", "rohan");
payload.put("age", 35);
Message<?> message = MessageBuilder.withPayload(payload).build();
waitFor(handler.handleMessage(message));
Flux<?> all = client.execute("SELECT name, age FROM person")
.fetch().all();
all.as(StepVerifier::create)
.expectNextCount(1)
.verifyComplete();
}
@Test
public void validateMessageHandlingWithDefaultUpdateCollection() {
R2dbcMessageHandler handler = new R2dbcMessageHandler(this.entityTemplate);
handler.setBeanFactory(mock(BeanFactory.class));
handler.setApplicationContext(mock(ApplicationContext.class, Answers.RETURNS_MOCKS));
handler.afterPropertiesSet();
Message<Person> message = MessageBuilder.withPayload(this.createPerson("Bob", 35)).build();
waitFor(handler.handleMessage(message));
handler.setQueryType(R2dbcMessageHandler.Type.UPDATE);
Person person = this.client.select()
.from("person")
.as(Person.class)
.fetch()
.first()
.block();
person.setAge(40);
message = MessageBuilder.withPayload(person)
.build();
waitFor(handler.handleMessage(message));
personRepository.findAll()
.as(StepVerifier::create)
.consumeNextWith(p -> Assert.assertEquals(Optional.of(40), Optional.ofNullable(p.age)))
.verifyComplete();
}
@Test
public void validateMessageHandlingWithUpdateQueryCollection() {
R2dbcMessageHandler handler = new R2dbcMessageHandler(this.entityTemplate);
handler.setBeanFactory(mock(BeanFactory.class));
handler.setApplicationContext(mock(ApplicationContext.class, Answers.RETURNS_MOCKS));
handler.setValuesExpression(new FunctionExpression<Message<?>>(Message::getPayload));
handler.setQueryType(R2dbcMessageHandler.Type.INSERT);
handler.setTableName("person");
handler.afterPropertiesSet();
Map<String, Object> payload = new HashMap<>();
payload.put("name", "Bob");
payload.put("age", 35);
Message<?> message = MessageBuilder.withPayload(payload).build();
waitFor(handler.handleMessage(message));
payload = new HashMap<>();
payload.put("name", "Rob");
payload.put("age", 43);
message = MessageBuilder.withPayload(payload).build();
waitFor(handler.handleMessage(message));
payload = new HashMap<>();
handler.setQueryType(R2dbcMessageHandler.Type.UPDATE);
Object insertedId = client.execute("SELECT id FROM person")
.fetch()
.first()
.block()
.get("id");
handler.setCriteriaExpression(new FunctionExpression<Message<?>>((m) -> Criteria.where("id").is(insertedId)));
payload.put("age", 40);
message = MessageBuilder.withPayload(payload).build();
waitFor(handler.handleMessage(message));
Flux<?> all = client.execute("SELECT age,name FROM person where age=40")
.fetch().all();
all.as(StepVerifier::create)
.consumeNextWith(response -> Assert.assertEquals("{AGE=40, NAME=Bob}", response.toString()))
.verifyComplete();
}
@Test
public void validateMessageHandlingWithDefaultDeleteCollection() {
R2dbcMessageHandler handler = new R2dbcMessageHandler(this.entityTemplate);
handler.setBeanFactory(mock(BeanFactory.class));
handler.setApplicationContext(mock(ApplicationContext.class, Answers.RETURNS_MOCKS));
handler.afterPropertiesSet();
Message<Person> message = MessageBuilder.withPayload(this.createPerson("Bob", 35)).build();
waitFor(handler.handleMessage(message));
Person person = this.client
.select()
.from("person")
.as(Person.class)
.fetch()
.first()
.block();
handler.setQueryType(R2dbcMessageHandler.Type.DELETE);
message = MessageBuilder.withPayload(person).build();
waitFor(handler.handleMessage(message));
personRepository.findAll()
.as(StepVerifier::create)
.expectNextCount(0)
.verifyComplete();
}
@Test
public void validateMessageHandlingWithDeleteQueryCollection() {
R2dbcMessageHandler handler = new R2dbcMessageHandler(this.entityTemplate);
handler.setBeanFactory(mock(BeanFactory.class));
handler.setApplicationContext(mock(ApplicationContext.class, Answers.RETURNS_MOCKS));
handler.setValuesExpression(new FunctionExpression<Message<?>>(Message::getPayload));
handler.setQueryType(R2dbcMessageHandler.Type.INSERT);
handler.setTableName("person");
handler.afterPropertiesSet();
Map<String, Object> payload = new HashMap<>();
payload.put("name", "Bob");
payload.put("age", 35);
Message<?> message = MessageBuilder.withPayload(payload).build();
waitFor(handler.handleMessage(message));
payload = new HashMap<>();
handler.setQueryType(R2dbcMessageHandler.Type.DELETE);
Object insertedId = client.execute("SELECT id FROM person")
.fetch()
.first()
.block()
.get("id");
handler.setCriteriaExpression(new FunctionExpression<Message<?>>((m) -> Criteria.where("id").is(insertedId)));
message = MessageBuilder.withPayload(payload).build();
waitFor(handler.handleMessage(message));
Flux<?> all = client.execute("SELECT age,name FROM person where age=35")
.fetch().all();
all.as(StepVerifier::create)
.expectNextCount(0)
.verifyComplete();
}
@Test
public void validateMessageHandlingWithDeleteQueryCollection_MultipleRows() {
R2dbcMessageHandler handler = new R2dbcMessageHandler(this.entityTemplate);
handler.setBeanFactory(mock(BeanFactory.class));
handler.setApplicationContext(mock(ApplicationContext.class, Answers.RETURNS_MOCKS));
handler.setValuesExpression(new FunctionExpression<Message<?>>(Message::getPayload));
handler.setQueryType(R2dbcMessageHandler.Type.INSERT);
handler.setTableName("person");
handler.afterPropertiesSet();
Map<String, Object> payload = new HashMap<>();
payload.put("name", "Bob");
payload.put("age", 35);
Message<?> message = MessageBuilder.withPayload(payload).build();
waitFor(handler.handleMessage(message));
payload = new HashMap<>();
payload.put("name", "Rob");
payload.put("age", 40);
message = MessageBuilder.withPayload(payload).build();
waitFor(handler.handleMessage(message));
payload = new HashMap<>();
handler.setQueryType(R2dbcMessageHandler.Type.DELETE);
Object insertedId = client.execute("SELECT id FROM person")
.fetch()
.first()
.block()
.get("id");
handler.setCriteriaExpression(new FunctionExpression<Message<?>>((m) -> Criteria.where("id").is(insertedId)));
message = MessageBuilder.withPayload(payload).build();
waitFor(handler.handleMessage(message));
Flux<?> all = client.execute("SELECT age,name FROM person where age=40")
.fetch().all();
all.as(StepVerifier::create)
.expectNextCount(1)
.verifyComplete();
}
private Person createPerson(String bob, Integer age) {
return new Person(bob, age);
}
private static <T> T waitFor(Mono<T> mono) {
return mono.block(Duration.ofSeconds(10));
}
interface PersonRepository extends ReactiveCrudRepository<Person, Integer> {
}
}

View File

@@ -57,6 +57,8 @@ include::./mongodb.adoc[]
include::./mqtt.adoc[]
include::./r2dbc.adoc[]
include::./redis.adoc[]
include::./resource.adoc[]

View File

@@ -38,6 +38,7 @@ This documentation is also available as single searchable link:index-single.html
<<./mail.adoc#mail,Mail Support>> ::
<<./mongodb.adoc#mongodb,MongoDb Support>> ::
<<./mqtt.adoc#mqtt,MQTT Support>> ::
<<./r2dbc.adoc#r2dbc,R2DBC Support>> ::
<<./redis.adoc#redis,Redis Support>> ::
<<./resource.adoc#resource,Resource Support>> ::
<<./rmi.adoc#rmi,RMI Support>> ::

View File

@@ -0,0 +1,34 @@
[[r2dbc]]
== R2DBC Support
Spring Integration provides channel adapters for receiving and sending messages by using reactive access to databases via https://r2dbc.io/[R2DBC] drivers.
You need to include this dependency into your project:
====
.Maven
[source, xml, subs="normal"]
----
<dependency>
<groupId>org.springframework.integration</groupId>
<artifactId>spring-integration-r2dbc</artifactId>
<version>{project-version}</version>
</dependency>
----
.Gradle
[source, groovy, subs="normal"]
----
compile "org.springframework.integration:spring-integration-r2dbc:{project-version}"
----
====
[[r2dbc-inbound-channel-adapter]]
=== Inbound Channel Adapter
TBD
[[r2dbc-outbound-channel-adapter]]
=== Outbound Channel Adapter
TBD

View File

@@ -20,6 +20,11 @@ If you are interested in more details, see the Issue Tracker tickets that were r
The standalone https://projects.spring.io/spring-integration-kafka/[Spring Integration Kafka] project has been merged as a `spring-integration-kafka` module to this project.
See <<./kafka.adoc#kafka,Spring for Apache Kafka Support>> for more information.
==== R2DBC Channel Adapters
The Channel Adapters for R2DBC database interaction have been introduced.
See <<./r2dbc.adoc#r2dbc,R2DBC Support>> for more information.
[[x5.4-general]]
=== General Changes