From 6d37fde4c76813b4c0e68a1e0f51da7143af5ccd Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Fri, 22 Nov 2019 14:51:54 +0100 Subject: [PATCH] #235 - Add Modifying query annotation. We now support returning the affected rows count for repository query methods that are annotated with the Modifying annotation. A modifying query method can return either the affected row count, a boolean value whether at least one row was updated or suppress value emission. @Query("UPDATE person SET firstname = :firstname where lastname = :lastname") Mono setFixedFirstnameFor(String firstname, String lastname); Original pull request: #238. --- src/main/asciidoc/new-features.adoc | 5 +++ .../reference/r2dbc-repositories.adoc | 26 ++++++++++++ .../data/r2dbc/repository/Modifying.java | 36 ++++++++++++++++ .../repository/query/AbstractR2dbcQuery.java | 31 +++++++++++--- .../repository/query/R2dbcQueryMethod.java | 5 ++- .../H2R2dbcRepositoryIntegrationTests.java | 41 +++++++++++++++++++ .../query/R2dbcQueryMethodUnitTests.java | 22 +++++++++- 7 files changed, 159 insertions(+), 7 deletions(-) create mode 100644 src/main/java/org/springframework/data/r2dbc/repository/Modifying.java diff --git a/src/main/asciidoc/new-features.adoc b/src/main/asciidoc/new-features.adoc index d630c52..da79a55 100644 --- a/src/main/asciidoc/new-features.adoc +++ b/src/main/asciidoc/new-features.adoc @@ -1,6 +1,11 @@ [[new-features]] = New & Noteworthy +[[new-features.1-0-0-RELEASE]] +== What's New in Spring Data R2DBC 1.0.0 RELEASE + +* `@Modifying` annotation for query methods to consume affected row count + [[new-features.1-0-0-RC1]] == What's New in Spring Data R2DBC 1.0.0 RC1 diff --git a/src/main/asciidoc/reference/r2dbc-repositories.adoc b/src/main/asciidoc/reference/r2dbc-repositories.adoc index 5e36e76..c1c3738 100644 --- a/src/main/asciidoc/reference/r2dbc-repositories.adoc +++ b/src/main/asciidoc/reference/r2dbc-repositories.adoc @@ -123,6 +123,32 @@ NOTE: R2DBC repositories do not support query derivation. NOTE: R2DBC repositories internally bind parameters to placeholders with `Statement.bind(…)` by index. +[[r2dbc.repositories.modifying]] +=== Modifying Queries + +The previous sections describe how to declare queries to access a given entity or collection of entities. +You can add custom modifying behavior by using the facilities described in <>. +As this approach is feasible for comprehensive custom functionality, you can modify queries that only need parameter binding by annotating the query method with `@Modifying`, as shown in the following example: + +Declaring manipulating queries + +==== +[source,java] +---- +@Query("UPDATE person SET firstname = :firstname where lastname = :lastname") +Mono setFixedFirstnameFor(String firstname, String lastname); +---- +==== + +The result of a modifying query can be: + +* `Void` to discard update count and await completion +* `Integer` emitting the affected rows count +* `Boolean` to emit whether at least one row was updated + +The `@Modifying` annotation is only relevant in combination with the `@Query` annotation. +Derived custom methods do not require this annotation. + :projection-collection: Flux include::../{spring-data-commons-docs}/repository-projections.adoc[leveloffset=+2] diff --git a/src/main/java/org/springframework/data/r2dbc/repository/Modifying.java b/src/main/java/org/springframework/data/r2dbc/repository/Modifying.java new file mode 100644 index 0000000..e438345 --- /dev/null +++ b/src/main/java/org/springframework/data/r2dbc/repository/Modifying.java @@ -0,0 +1,36 @@ +/* + * Copyright 2019 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.r2dbc.repository; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Indicates a query method should be considered a modifying query as that changes the way it needs to be executed. + *

+ * Queries that should be annotated with a {@code @Modifying} annotation include {@code INSERT}, {@code UPDATE}, + * {@code DELETE}, and DDL statements. The result of these queries can be consumed as affected row count. + * + * @author Mark Paluch + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.METHOD, ElementType.ANNOTATION_TYPE }) +@Documented +public @interface Modifying { +} diff --git a/src/main/java/org/springframework/data/r2dbc/repository/query/AbstractR2dbcQuery.java b/src/main/java/org/springframework/data/r2dbc/repository/query/AbstractR2dbcQuery.java index 531f1b5..397d760 100644 --- a/src/main/java/org/springframework/data/r2dbc/repository/query/AbstractR2dbcQuery.java +++ b/src/main/java/org/springframework/data/r2dbc/repository/query/AbstractR2dbcQuery.java @@ -24,8 +24,8 @@ import org.springframework.core.convert.converter.Converter; import org.springframework.data.convert.EntityInstantiators; import org.springframework.data.r2dbc.convert.R2dbcConverter; import org.springframework.data.r2dbc.core.DatabaseClient; -import org.springframework.data.r2dbc.core.FetchSpec; import org.springframework.data.r2dbc.core.DatabaseClient.GenericExecuteSpec; +import org.springframework.data.r2dbc.core.FetchSpec; import org.springframework.data.r2dbc.repository.query.R2dbcQueryExecution.ResultProcessingConverter; import org.springframework.data.r2dbc.repository.query.R2dbcQueryExecution.ResultProcessingExecution; import org.springframework.data.relational.repository.query.RelationalParameterAccessor; @@ -108,7 +108,7 @@ public abstract class AbstractR2dbcQuery implements RepositoryQuery { String tableName = method.getEntityInformation().getTableName(); - R2dbcQueryExecution execution = getExecution( + R2dbcQueryExecution execution = getExecution(processor.getReturnedType(), new ResultProcessingConverter(processor, converter.getMappingContext(), instantiators)); return execution.execute(fetchSpec, processor.getReturnedType().getDomainType(), tableName); @@ -124,14 +124,35 @@ public abstract class AbstractR2dbcQuery implements RepositoryQuery { /** * Returns the execution instance to use. * + * @param returnedType must not be {@literal null}. * @param resultProcessing must not be {@literal null}. * @return */ - private R2dbcQueryExecution getExecution(Converter resultProcessing) { - return new ResultProcessingExecution(getExecutionToWrap(), resultProcessing); + private R2dbcQueryExecution getExecution(ReturnedType returnedType, Converter resultProcessing) { + return new ResultProcessingExecution(getExecutionToWrap(returnedType), resultProcessing); } - private R2dbcQueryExecution getExecutionToWrap() { + private R2dbcQueryExecution getExecutionToWrap(ReturnedType returnedType) { + + if (method.isModifyingQuery()) { + + if (Boolean.class.isAssignableFrom(returnedType.getReturnedType())) { + return (q, t, c) -> q.rowsUpdated().map(integer -> integer > 0); + } + + if (Number.class.isAssignableFrom(returnedType.getReturnedType())) { + + return (q, t, c) -> q.rowsUpdated().map(integer -> { + return converter.getConversionService().convert(integer, returnedType.getReturnedType()); + }); + } + + if (Void.class.isAssignableFrom(returnedType.getReturnedType())) { + return (q, t, c) -> q.rowsUpdated().then(); + } + + return (q, t, c) -> q.rowsUpdated(); + } if (method.isCollectionQuery()) { return (q, t, c) -> q.all(); diff --git a/src/main/java/org/springframework/data/r2dbc/repository/query/R2dbcQueryMethod.java b/src/main/java/org/springframework/data/r2dbc/repository/query/R2dbcQueryMethod.java index 3f33748..ce0162a 100644 --- a/src/main/java/org/springframework/data/r2dbc/repository/query/R2dbcQueryMethod.java +++ b/src/main/java/org/springframework/data/r2dbc/repository/query/R2dbcQueryMethod.java @@ -28,6 +28,7 @@ import org.springframework.data.domain.Slice; import org.springframework.data.domain.Sort; import org.springframework.data.mapping.context.MappingContext; import org.springframework.data.projection.ProjectionFactory; +import org.springframework.data.r2dbc.repository.Modifying; import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; import org.springframework.data.relational.repository.query.RelationalEntityMetadata; @@ -60,6 +61,7 @@ public class R2dbcQueryMethod extends QueryMethod { private final Method method; private final MappingContext, ? extends RelationalPersistentProperty> mappingContext; private final Optional query; + private final boolean modifying; private @Nullable RelationalEntityMetadata metadata; @@ -109,6 +111,7 @@ public class R2dbcQueryMethod extends QueryMethod { this.method = method; this.query = Optional.ofNullable(AnnotatedElementUtils.findMergedAnnotation(method, Query.class)); + this.modifying = AnnotatedElementUtils.hasAnnotation(method, Modifying.class); } /* (non-Javadoc) @@ -132,7 +135,7 @@ public class R2dbcQueryMethod extends QueryMethod { */ @Override public boolean isModifyingQuery() { - return super.isModifyingQuery(); + return modifying; } /* diff --git a/src/test/java/org/springframework/data/r2dbc/repository/H2R2dbcRepositoryIntegrationTests.java b/src/test/java/org/springframework/data/r2dbc/repository/H2R2dbcRepositoryIntegrationTests.java index d0a7546..0653183 100644 --- a/src/test/java/org/springframework/data/r2dbc/repository/H2R2dbcRepositoryIntegrationTests.java +++ b/src/test/java/org/springframework/data/r2dbc/repository/H2R2dbcRepositoryIntegrationTests.java @@ -18,11 +18,14 @@ package org.springframework.data.r2dbc.repository; import io.r2dbc.spi.ConnectionFactory; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; import javax.sql.DataSource; +import org.junit.Test; import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan.Filter; import org.springframework.context.annotation.Configuration; @@ -44,6 +47,8 @@ import org.springframework.test.context.junit4.SpringRunner; @ContextConfiguration public class H2R2dbcRepositoryIntegrationTests extends AbstractR2dbcRepositoryIntegrationTests { + @Autowired private H2LegoSetRepository repository; + @Configuration @EnableR2dbcRepositories(considerNestedRepositories = true, includeFilters = @Filter(classes = H2LegoSetRepository.class, type = FilterType.ASSIGNABLE_TYPE)) @@ -76,6 +81,30 @@ public class H2R2dbcRepositoryIntegrationTests extends AbstractR2dbcRepositoryIn return H2LegoSetRepository.class; } + @Test // gh-235 + public void shouldReturnUpdateCount() { + + shouldInsertNewItems(); + + repository.updateManual(42).as(StepVerifier::create).expectNext(2L).verifyComplete(); + } + + @Test // gh-235 + public void shouldReturnUpdateSuccess() { + + shouldInsertNewItems(); + + repository.updateManualAndReturnBoolean(42).as(StepVerifier::create).expectNext(true).verifyComplete(); + } + + @Test // gh-235 + public void shouldNotReturnUpdateCount() { + + shouldInsertNewItems(); + + repository.updateManualAndReturnNothing(42).as(StepVerifier::create).verifyComplete(); + } + interface H2LegoSetRepository extends LegoSetRepository { @Override @@ -93,5 +122,17 @@ public class H2R2dbcRepositoryIntegrationTests extends AbstractR2dbcRepositoryIn @Override @Query("SELECT id FROM legoset") Flux findAllIds(); + + @Query("UPDATE legoset set manual = :manual") + @Modifying + Mono updateManual(int manual); + + @Query("UPDATE legoset set manual = :manual") + @Modifying + Mono updateManualAndReturnBoolean(int manual); + + @Query("UPDATE legoset set manual = :manual") + @Modifying + Mono updateManualAndReturnNothing(int manual); } } diff --git a/src/test/java/org/springframework/data/r2dbc/repository/query/R2dbcQueryMethodUnitTests.java b/src/test/java/org/springframework/data/r2dbc/repository/query/R2dbcQueryMethodUnitTests.java index 7edf06a..ed77137 100644 --- a/src/test/java/org/springframework/data/r2dbc/repository/query/R2dbcQueryMethodUnitTests.java +++ b/src/test/java/org/springframework/data/r2dbc/repository/query/R2dbcQueryMethodUnitTests.java @@ -19,17 +19,21 @@ import static org.assertj.core.api.Assertions.*; import reactor.core.publisher.Mono; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; import java.lang.reflect.Method; import java.util.List; import org.junit.Before; import org.junit.Test; + import org.springframework.dao.InvalidDataAccessApiUsageException; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.data.projection.ProjectionFactory; import org.springframework.data.projection.SpelAwareProxyProjectionFactory; +import org.springframework.data.r2dbc.repository.Modifying; import org.springframework.data.relational.core.mapping.RelationalMappingContext; import org.springframework.data.relational.repository.query.RelationalEntityMetadata; import org.springframework.data.repository.Repository; @@ -59,7 +63,15 @@ public class R2dbcQueryMethodUnitTests { assertThat(metadata.getTableName()).isEqualTo("contact"); } - @Test + @Test // gh-235 + public void detectsModifyingQuery() throws Exception { + + R2dbcQueryMethod queryMethod = queryMethod(SampleRepository.class, "method"); + + assertThat(queryMethod.isModifyingQuery()).isTrue(); + } + + @Test // gh-235 public void detectsTableNameFromRepoTypeIfReturnTypeNotAssignable() throws Exception { R2dbcQueryMethod queryMethod = queryMethod(SampleRepository.class, "differentTable"); @@ -67,6 +79,7 @@ public class R2dbcQueryMethodUnitTests { assertThat(metadata.getJavaType()).isAssignableFrom(Address.class); assertThat(metadata.getTableName()).isEqualTo("contact"); + assertThat(queryMethod.isModifyingQuery()).isFalse(); } @Test(expected = IllegalArgumentException.class) @@ -126,6 +139,7 @@ public class R2dbcQueryMethodUnitTests { interface SampleRepository extends Repository { + @MyModifyingAnnotation List method(); List

differentTable(); @@ -138,4 +152,10 @@ public class R2dbcQueryMethodUnitTests { static class Contact {} static class Address {} + + @Retention(RetentionPolicy.RUNTIME) + @Modifying + public @interface MyModifyingAnnotation { + } + }