From f7dd876be2946c54a77eee8fa37912cda67814b9 Mon Sep 17 00:00:00 2001 From: Artem Bilan Date: Tue, 11 Oct 2022 14:39:29 -0400 Subject: [PATCH] INT-3333: Return empty list as is from DB gateway (#3907) * INT-3333: Return empty list as is from DB gateway Fixes https://jira.spring.io/browse/INT-3333 In `JdbcOutboundGateway` and `JpaOutboundGateway` the empty result list is treated as "no reply" and therefore `null` is returned cause the flow to stop at this point. It is better to return such a result as is and the target application to decided what to do with it, e.g. a `discardChannel` on a downstream splitter configuration. NOTE: the `MongoDbOutboundGateway` doesn't treat an empty result as a `null` * * Fix language in docs Co-authored-by: Gary Russell Co-authored-by: Gary Russell --- .../integration/jdbc/JdbcOutboundGateway.java | 5 +- .../integration/jpa/core/JpaExecutor.java | 47 ++--- .../jpa/inbound/JpaPollingChannelAdapter.java | 15 +- .../jpa/core/JpaExecutorTests.java | 9 +- .../jpa/outbound/JpaOutboundGatewayTests.java | 4 +- src/reference/asciidoc/jdbc.adoc | 4 + src/reference/asciidoc/jpa.adoc | 169 +++++++++--------- src/reference/asciidoc/whats-new.adoc | 3 + 8 files changed, 125 insertions(+), 131 deletions(-) diff --git a/spring-integration-jdbc/src/main/java/org/springframework/integration/jdbc/JdbcOutboundGateway.java b/spring-integration-jdbc/src/main/java/org/springframework/integration/jdbc/JdbcOutboundGateway.java index b31d5b8b5f..88ce8b59ac 100644 --- a/spring-integration-jdbc/src/main/java/org/springframework/integration/jdbc/JdbcOutboundGateway.java +++ b/spring-integration-jdbc/src/main/java/org/springframework/integration/jdbc/JdbcOutboundGateway.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 the original author or authors. + * Copyright 2002-2022 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. @@ -221,9 +221,6 @@ public class JdbcOutboundGateway extends AbstractReplyProducingMessageHandler { list = this.poller.doPoll(sqlQueryParameterSource); } Object payload = list; - if (list.isEmpty()) { - return null; - } if (list.size() == 1 && (this.maxRows == null || this.maxRows == 1)) { payload = list.get(0); } diff --git a/spring-integration-jpa/src/main/java/org/springframework/integration/jpa/core/JpaExecutor.java b/spring-integration-jpa/src/main/java/org/springframework/integration/jpa/core/JpaExecutor.java index 90b422199d..75c402078a 100644 --- a/spring-integration-jpa/src/main/java/org/springframework/integration/jpa/core/JpaExecutor.java +++ b/spring-integration-jpa/src/main/java/org/springframework/integration/jpa/core/JpaExecutor.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 the original author or authors. + * Copyright 2002-2022 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. @@ -51,7 +51,6 @@ import org.springframework.util.CollectionUtils; *
  • JpQl Named Query
  • *
  • Sql Native Named Query
  • * . - * * When objects are being retrieved, it also possibly to: * *
      @@ -176,8 +175,8 @@ public class JpaExecutor implements InitializingBean, BeanFactoryAware { * @param jpaQuery The provided JPA query must neither be null nor empty. */ public void setJpaQuery(String jpaQuery) { - Assert.isTrue(this.nativeQuery == null && this.namedQuery == null, "You can define only one of the " - + "properties 'jpaQuery', 'nativeQuery', 'namedQuery'"); + Assert.isTrue(this.nativeQuery == null && this.namedQuery == null, + "Only one of the properties 'jpaQuery', 'nativeQuery', 'namedQuery' can be defined"); Assert.hasText(jpaQuery, "jpaQuery must neither be null nor empty."); this.jpaQuery = jpaQuery; } @@ -190,8 +189,8 @@ public class JpaExecutor implements InitializingBean, BeanFactoryAware { * @param nativeQuery The provided SQL query must neither be null nor empty. */ public void setNativeQuery(String nativeQuery) { - Assert.isTrue(this.namedQuery == null && this.jpaQuery == null, "You can define only one of the " - + "properties 'jpaQuery', 'nativeQuery', 'namedQuery'"); + Assert.isTrue(this.namedQuery == null && this.jpaQuery == null, + "Only one of the properties 'jpaQuery', 'nativeQuery', 'namedQuery' can be defined"); Assert.hasText(nativeQuery, "nativeQuery must neither be null nor empty."); this.nativeQuery = nativeQuery; } @@ -202,8 +201,8 @@ public class JpaExecutor implements InitializingBean, BeanFactoryAware { * @param namedQuery Must neither be null nor empty */ public void setNamedQuery(String namedQuery) { - Assert.isTrue(this.jpaQuery == null && this.nativeQuery == null, "You can define only one of the " - + "properties 'jpaQuery', 'nativeQuery', 'namedQuery'"); + Assert.isTrue(this.jpaQuery == null && this.nativeQuery == null, + "Only one of the properties 'jpaQuery', 'nativeQuery', 'namedQuery' can be defined"); Assert.hasText(namedQuery, "namedQuery must neither be null nor empty."); this.namedQuery = namedQuery; } @@ -312,7 +311,6 @@ public class JpaExecutor implements InitializingBean, BeanFactoryAware { *

      If set to false, the complete result list is returned as the * payload. * @param expectSingleResult true if a single object is expected. - * */ public void setExpectSingleResult(boolean expectSingleResult) { this.expectSingleResult = expectSingleResult; @@ -339,7 +337,7 @@ public class JpaExecutor implements InitializingBean, BeanFactoryAware { } /** - * Set the expression for maximum number of results expression. It has be a non null value + * Set the expression for maximum number of results expression. It has to be a non-null value * Not setting one will default to the behavior of fetching all the records * @param maxResultsExpression The maximum results expression. */ @@ -413,7 +411,7 @@ public class JpaExecutor implements InitializingBean, BeanFactoryAware { /** * Execute the actual Jpa Operation. Call this method, if you need access to - * process return values. This methods return a Map that contains either + * process return values. These methods return a Map that contains either * the number of affected entities or the affected entity itself. *

      Keep in mind that the number of entities effected by the operation may * not necessarily correlate with the number of rows effected in the database. @@ -497,7 +495,7 @@ public class JpaExecutor implements InitializingBean, BeanFactoryAware { payload = this.jpaOperations.find(entityClazz, id); } else { - final List result; + List result; int maxNumberOfResults = evaluateExpressionForNumericResult(requestMessage, this.maxResultsExpression); if (requestMessage == null) { result = doPoll(this.parameterSource, 0, maxNumberOfResults); @@ -511,27 +509,18 @@ public class JpaExecutor implements InitializingBean, BeanFactoryAware { result = doPoll(paramSource, firstResult, maxNumberOfResults); } - if (result.isEmpty()) { - payload = null; - } - else { - if (this.expectSingleResult) { - if (result.size() == 1) { - payload = result.iterator().next(); - } - else if (requestMessage != null) { - throw new MessagingException(requestMessage, - "The Jpa operation returned more than 1 result for expectSingleResult mode."); - } - else { - throw new MessagingException( - "The Jpa operation returned more than 1 result for expectSingleResult mode."); - } + if (this.expectSingleResult && !result.isEmpty()) { + if (result.size() == 1) { + payload = result.iterator().next(); } else { - payload = result; + throw new MessagingException(requestMessage, // NOSONAR + "The Jpa operation returned more than 1 result for expectSingleResult mode."); } } + else { + payload = result; + } } checkDelete(payload); diff --git a/spring-integration-jpa/src/main/java/org/springframework/integration/jpa/inbound/JpaPollingChannelAdapter.java b/spring-integration-jpa/src/main/java/org/springframework/integration/jpa/inbound/JpaPollingChannelAdapter.java index c52f6dc4d2..a0a8af3285 100644 --- a/spring-integration-jpa/src/main/java/org/springframework/integration/jpa/inbound/JpaPollingChannelAdapter.java +++ b/spring-integration-jpa/src/main/java/org/springframework/integration/jpa/inbound/JpaPollingChannelAdapter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 the original author or authors. + * Copyright 2002-2022 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. @@ -19,6 +19,7 @@ package org.springframework.integration.jpa.inbound; import org.springframework.integration.endpoint.AbstractMessageSource; import org.springframework.integration.jpa.core.JpaExecutor; import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; /** * Polling message source that produces messages from the result of the provided: @@ -30,11 +31,8 @@ import org.springframework.util.Assert; *

    • JpQl Named Query
    • *
    • Sql Native Named Query
    • *
    . - * * After the objects have been polled, it also possibly to either: * - * executes an update after the select possibly to updated the state of selected records - * *
      *
    • executes an update (per retrieved object or for the entire payload)
    • *
    • delete the retrieved object
    • @@ -73,13 +71,14 @@ public class JpaPollingChannelAdapter extends AbstractMessageSource { /** * Use {@link JpaExecutor#poll()} to executes the JPA operation. - * If {@link JpaExecutor#poll()} returns null, this method will return - * null. Otherwise, a new {@link org.springframework.messaging.Message} - * is constructed and returned. + * Return {@code null} if result of {@link JpaExecutor#poll()} is {@link ObjectUtils#isEmpty}. + * The empty collection means there is no data to retrieve from DB at the moment therefore + * no reason to emit an empty message from this message source. */ @Override protected Object doReceive() { - return this.jpaExecutor.poll(); + Object result = this.jpaExecutor.poll(); + return ObjectUtils.isEmpty(result) ? null : result; } @Override diff --git a/spring-integration-jpa/src/test/java/org/springframework/integration/jpa/core/JpaExecutorTests.java b/spring-integration-jpa/src/test/java/org/springframework/integration/jpa/core/JpaExecutorTests.java index 7226893084..f790268421 100644 --- a/spring-integration-jpa/src/test/java/org/springframework/integration/jpa/core/JpaExecutorTests.java +++ b/spring-integration-jpa/src/test/java/org/springframework/integration/jpa/core/JpaExecutorTests.java @@ -94,15 +94,13 @@ public class JpaExecutorTests { assertThatIllegalArgumentException() .isThrownBy(() -> executor.setNamedQuery("NamedQuery")) - .withMessage("You can define only one of the " - + "properties 'jpaQuery', 'nativeQuery', 'namedQuery'"); + .withMessage("Only one of the properties 'jpaQuery', 'nativeQuery', 'namedQuery' can be defined"); assertThat(TestUtils.getPropertyValue(executor, "namedQuery")).isNull(); assertThatIllegalArgumentException() .isThrownBy(() -> executor.setNativeQuery("select * from Student")) - .withMessage("You can define only one of the " - + "properties 'jpaQuery', 'nativeQuery', 'namedQuery'"); + .withMessage("Only one of the properties 'jpaQuery', 'nativeQuery', 'namedQuery' can be defined"); assertThat(TestUtils.getPropertyValue(executor, "nativeQuery")).isNull(); @@ -112,8 +110,7 @@ public class JpaExecutorTests { assertThatIllegalArgumentException() .isThrownBy(() -> executor2.setJpaQuery("select s from Student s")) - .withMessage("You can define only one of the " - + "properties 'jpaQuery', 'nativeQuery', 'namedQuery'"); + .withMessage("Only one of the properties 'jpaQuery', 'nativeQuery', 'namedQuery' can be defined"); assertThat(TestUtils.getPropertyValue(executor2, "jpaQuery")).isNull(); } diff --git a/spring-integration-jpa/src/test/java/org/springframework/integration/jpa/outbound/JpaOutboundGatewayTests.java b/spring-integration-jpa/src/test/java/org/springframework/integration/jpa/outbound/JpaOutboundGatewayTests.java index 62f0d89610..6df7421a7f 100644 --- a/spring-integration-jpa/src/test/java/org/springframework/integration/jpa/outbound/JpaOutboundGatewayTests.java +++ b/spring-integration-jpa/src/test/java/org/springframework/integration/jpa/outbound/JpaOutboundGatewayTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 the original author or authors. + * Copyright 2002-2022 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. @@ -160,7 +160,7 @@ public class JpaOutboundGatewayTests { List allStudents = this.studentService.getAllStudents(); assertThat(allStudents).hasSize(3); this.studentService.deleteStudents(allStudents); - assertThat(this.studentService.getAllStudents()).isNull(); + assertThat(this.studentService.getAllStudents()).isEmpty(); } } diff --git a/src/reference/asciidoc/jdbc.adoc b/src/reference/asciidoc/jdbc.adoc index f01d0baf0c..0a051b7bf6 100644 --- a/src/reference/asciidoc/jdbc.adoc +++ b/src/reference/asciidoc/jdbc.adoc @@ -365,6 +365,10 @@ It can also have a `SqlParameterSourceFactory` injected to control the binding o Starting with the version 4.2, the `request-prepared-statement-setter` attribute is available on the `` as an alternative to `request-sql-parameter-source-factory`. It lets you specify a `MessagePreparedStatementSetter` bean reference, which implements more sophisticated `PreparedStatement` preparation before its execution. +Starting with the version 6.0, the `JdbcOutboundGateway` returns an empty list result as is instead of converting it to `null` as it was before with the meaning "no reply". +This caused an extra configuration in applications where handling of empty lists is a part of downstream logic. +See <<./splitter.adoc#split-stream-and-flux,Splitter Discard Channel>> for possible empty list handling option. + See <> for more information about `MessagePreparedStatementSetter`. [[jdbc-message-store]] diff --git a/src/reference/asciidoc/jpa.adoc b/src/reference/asciidoc/jpa.adoc index 70e6fa03e2..c4baf64eab 100644 --- a/src/reference/asciidoc/jpa.adoc +++ b/src/reference/asciidoc/jpa.adoc @@ -1011,10 +1011,89 @@ public class JpaJavaApplication { [[jpa-retrieving-outbound-gateway]] ==== Retrieving Outbound Gateway -The following example shows all the attributes that you can set on a retrieving outbound gateway and describes the key attributes: +The following example demonstrates how to configure a retrieving outbound gateway: ==== -[source,xml] +[source, java, role="primary"] +.Java DSL +---- +@SpringBootApplication +@EntityScan(basePackageClasses = StudentDomain.class) +public class JpaJavaApplication { + + public static void main(String[] args) { + new SpringApplicationBuilder(JpaJavaApplication.class) + .web(false) + .run(args); + } + + @Autowired + private EntityManagerFactory entityManagerFactory; + + @Bean + public IntegrationFlow retrievingGatewayFlow() { + return f -> f + .handle(Jpa.retrievingGateway(this.entityManagerFactory) + .jpaQuery("from Student s where s.id = :id") + .expectSingleResult(true) + .parameterExpression("id", "payload")) + .channel(c -> c.queue("retrieveResults")); + } + +} +---- +[source, kotlin, role="secondary"] +.Kotlin DSL +---- +@Bean +fun retrievingGatewayFlow() = + integrationFlow { + handle(Jpa.retrievingGateway(this.entityManagerFactory) + .jpaQuery("from Student s where s.id = :id") + .expectSingleResult(true) + .parameterExpression("id", "payload")) + channel { queue("retrieveResults") } + } +---- +[source, java, role="secondary"] +.Java +---- +@SpringBootApplication +@EntityScan(basePackageClasses = StudentDomain.class) +public class JpaJavaApplication { + + public static void main(String[] args) { + new SpringApplicationBuilder(JpaJavaApplication.class) + .web(false) + .run(args); + } + + @Autowired + private EntityManagerFactory entityManagerFactory; + + + @Bean + public JpaExecutor jpaExecutor() { + JpaExecutor executor = new JpaExecutor(this.entityManagerFactory); + jpaExecutor.setJpaQuery("from Student s where s.id = :id"); + executor.setJpaParameters(Collections.singletonList(new JpaParameter("id", null, "payload"))); + jpaExecutor.setExpectSingleResult(true); + return executor; + } + + @Bean + @ServiceActivator(channel = "jpaRetrievingChannel") + public MessageHandler jpaOutbound() { + JpaOutboundGateway adapter = new JpaOutboundGateway(jpaExecutor()); + adapter.setOutputChannelName("retrieveResults"); + adapter.setGatewayType(OutboundGatewayType.RETRIEVING); + return adapter; + } + +} +---- +[source, xml, role="secondary"] +.XML ---- > and <>. - -==== Configuring with Java Configuration - -The following Spring Boot application shows an example of how configure the outbound adapter with Java: - -==== -[source, java] ----- -@SpringBootApplication -@EntityScan(basePackageClasses = StudentDomain.class) -public class JpaJavaApplication { - - public static void main(String[] args) { - new SpringApplicationBuilder(JpaJavaApplication.class) - .web(false) - .run(args); - } - - @Autowired - private EntityManagerFactory entityManagerFactory; - - - @Bean - public JpaExecutor jpaExecutor() { - JpaExecutor executor = new JpaExecutor(this.entityManagerFactory); - jpaExecutor.setJpaQuery("from Student s where s.id = :id"); - executor.setJpaParameters(Collections.singletonList(new JpaParameter("id", null, "payload"))); - jpaExecutor.setExpectSingleResult(true); - return executor; - } - - @Bean - @ServiceActivator(channel = "jpaRetrievingChannel") - public MessageHandler jpaOutbound() { - JpaOutboundGateway adapter = new JpaOutboundGateway(jpaExecutor()); - adapter.setOutputChannelName("retrieveResults"); - adapter.setGatewayType(OutboundGatewayType.RETRIEVING); - return adapter; - } - -} ----- -==== - -==== Configuring with the Java DSL - -The following Spring Boot application shows an example of how to configure the outbound adapter with the Java DSL: - -==== -[source, java] ----- -@SpringBootApplication -@EntityScan(basePackageClasses = StudentDomain.class) -public class JpaJavaApplication { - - public static void main(String[] args) { - new SpringApplicationBuilder(JpaJavaApplication.class) - .web(false) - .run(args); - } - - @Autowired - private EntityManagerFactory entityManagerFactory; - - @Bean - public IntegrationFlow retrievingGatewayFlow() { - return f -> f - .handle(Jpa.retrievingGateway(this.entityManagerFactory) - .jpaQuery("from Student s where s.id = :id") - .expectSingleResult(true) - .parameterExpression("id", "payload")) - .channel(c -> c.queue("retrieveResults")); - } - -} ----- -==== - [IMPORTANT] ==== When you choose to delete entities upon retrieval, and you have retrieved a collection of entities, by default, entities are deleted on a per-entity basis. @@ -1171,6 +1170,12 @@ It does not cascade to related entities.`" For more information, see https://jcp.org/en/jsr/detail?id=317[JSR 317: Java™ Persistence 2.0] ==== +NOTE: Starting with version 6.0, the `Jpa.retrievingGateway()` returns an empty list result when there are no entities returned by the query. +Previously `null` was returned ending the flow, or throwing an exception, depending on `requiresReply`. +Or, to revert to the previous behavior, add a `filter` after the gateway to filter out empty lists. +It requires extra configuration in applications where empty list handling is a part of the downstream logic. +See <<./splitter.adoc#split-stream-and-flux,Splitter Discard Channel>> for possible empty list handling options. + [[outboundGatewaySamples]] ==== JPA Outbound Gateway Samples diff --git a/src/reference/asciidoc/whats-new.adoc b/src/reference/asciidoc/whats-new.adoc index c311405043..3a4f5c53a2 100644 --- a/src/reference/asciidoc/whats-new.adoc +++ b/src/reference/asciidoc/whats-new.adoc @@ -100,6 +100,9 @@ See <<./gateway.adoc#async-gateway, Asynchronous Gateway>> for more information. The `integrationGlobalProperties` bean is now declared by the framework as an instance of `org.springframework.integration.context.IntegrationProperties` instead of the previously deprecated `java.util.Properties`. +Message handlers which produce a collection as a reply (e.g. `JpaOutboundGateway`, `JdbcOutboundGateway` and other DB-based gateways) now return an empty result list if no records are returned by the query. +Previously, `null` was returned ending the flow, or throwing an exception, depending on `requiresReply`. + [[x6.0-rmi]] === RMI Removal