From a53f4ac375982181f6521cc0737bb4fcbac8e64b Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Fri, 8 Oct 2021 13:56:34 +0200 Subject: [PATCH] Add support for fluent Query by Example query definition. Closes: #663 --- .../support/ReactiveFluentQuerySupport.java | 100 ++++++++ .../ReactivePageableExecutionUtils.java | 69 ++++++ .../support/SimpleR2dbcRepository.java | 126 ++++++++++ ...SimpleR2dbcRepositoryIntegrationTests.java | 215 ++++++++++++++++++ 4 files changed, 510 insertions(+) create mode 100644 src/main/java/org/springframework/data/r2dbc/repository/support/ReactiveFluentQuerySupport.java create mode 100644 src/main/java/org/springframework/data/r2dbc/repository/support/ReactivePageableExecutionUtils.java diff --git a/src/main/java/org/springframework/data/r2dbc/repository/support/ReactiveFluentQuerySupport.java b/src/main/java/org/springframework/data/r2dbc/repository/support/ReactiveFluentQuerySupport.java new file mode 100644 index 00000000..2836cbfb --- /dev/null +++ b/src/main/java/org/springframework/data/r2dbc/repository/support/ReactiveFluentQuerySupport.java @@ -0,0 +1,100 @@ +/* + * Copyright 2021 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.support; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import org.springframework.data.domain.Sort; +import org.springframework.data.repository.query.FluentQuery; +import org.springframework.util.Assert; + +/** + * Support class for {@link org.springframework.data.repository.query.FluentQuery.ReactiveFluentQuery} implementations. + * + * @author Mark Paluch + * @since 1.4 + */ +abstract class ReactiveFluentQuerySupport implements FluentQuery.ReactiveFluentQuery { + + private final P predicate; + private final Sort sort; + private final Class resultType; + private final List fieldsToInclude; + + ReactiveFluentQuerySupport(P predicate, Sort sort, Class resultType, List fieldsToInclude) { + this.predicate = predicate; + this.sort = sort; + this.resultType = resultType; + this.fieldsToInclude = fieldsToInclude; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.query.FluentQuery.ReactiveFluentQuery#sortBy(org.springframework.data.domain.Sort) + */ + @Override + public ReactiveFluentQuery sortBy(Sort sort) { + + Assert.notNull(sort, "Sort must not be null!"); + + return create(predicate, sort, resultType, fieldsToInclude); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.query.FluentQuery.ReactiveFluentQuery#as(java.lang.Class) + */ + @Override + public ReactiveFluentQuery as(Class projection) { + + Assert.notNull(projection, "Projection target type must not be null!"); + + return create(predicate, sort, projection, fieldsToInclude); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.query.FluentQuery.ReactiveFluentQuery#project(java.util.Collection) + */ + @Override + public ReactiveFluentQuery project(Collection properties) { + + Assert.notNull(properties, "Projection properties must not be null!"); + + return create(predicate, sort, resultType, new ArrayList<>(properties)); + } + + protected abstract ReactiveFluentQuerySupport create(P predicate, Sort sort, Class resultType, + List fieldsToInclude); + + P getPredicate() { + return predicate; + } + + Sort getSort() { + return sort; + } + + Class getResultType() { + return resultType; + } + + List getFieldsToInclude() { + return fieldsToInclude; + } +} diff --git a/src/main/java/org/springframework/data/r2dbc/repository/support/ReactivePageableExecutionUtils.java b/src/main/java/org/springframework/data/r2dbc/repository/support/ReactivePageableExecutionUtils.java new file mode 100644 index 00000000..dce0d9a4 --- /dev/null +++ b/src/main/java/org/springframework/data/r2dbc/repository/support/ReactivePageableExecutionUtils.java @@ -0,0 +1,69 @@ +/* + * Copyright 2021 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.support; + +import reactor.core.publisher.Mono; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.util.Assert; + +/** + * Support for query execution using {@link Pageable}. Using {@link ReactivePageableExecutionUtils} assumes that data + * queries are cheaper than {@code COUNT} queries and so some cases can take advantage of optimizations. + * + * @author Mark Paluch + * @since 1.4 + */ +abstract class ReactivePageableExecutionUtils { + + private ReactivePageableExecutionUtils() {} + + /** + * Constructs a {@link Page} based on the given {@code content}, {@link Pageable} and {@link Mono} applying + * optimizations. The construction of {@link Page} omits a count query if the total can be determined based on the + * result size and {@link Pageable}. + * + * @param content must not be {@literal null}. + * @param pageable must not be {@literal null}. + * @param totalSupplier must not be {@literal null}. + * @return the {@link Page}. + */ + public static Mono> getPage(List content, Pageable pageable, Mono totalSupplier) { + + Assert.notNull(content, "Content must not be null!"); + Assert.notNull(pageable, "Pageable must not be null!"); + Assert.notNull(totalSupplier, "TotalSupplier must not be null!"); + + if (pageable.isUnpaged() || pageable.getOffset() == 0) { + + if (pageable.isUnpaged() || pageable.getPageSize() > content.size()) { + return Mono.just(new PageImpl<>(content, pageable, content.size())); + } + + return totalSupplier.map(total -> new PageImpl<>(content, pageable, total)); + } + + if (content.size() != 0 && pageable.getPageSize() > content.size()) { + return Mono.just(new PageImpl<>(content, pageable, pageable.getOffset() + content.size())); + } + + return totalSupplier.map(total -> new PageImpl<>(content, pageable, total)); + } +} diff --git a/src/main/java/org/springframework/data/r2dbc/repository/support/SimpleR2dbcRepository.java b/src/main/java/org/springframework/data/r2dbc/repository/support/SimpleR2dbcRepository.java index 21551626..670277e5 100644 --- a/src/main/java/org/springframework/data/r2dbc/repository/support/SimpleR2dbcRepository.java +++ b/src/main/java/org/springframework/data/r2dbc/repository/support/SimpleR2dbcRepository.java @@ -18,21 +18,28 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import java.util.Collections; import java.util.List; +import java.util.function.Function; +import java.util.function.UnaryOperator; import org.reactivestreams.Publisher; import org.springframework.data.domain.Example; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.r2dbc.convert.R2dbcConverter; import org.springframework.data.r2dbc.core.R2dbcEntityOperations; import org.springframework.data.r2dbc.core.R2dbcEntityTemplate; import org.springframework.data.r2dbc.core.ReactiveDataAccessStrategy; +import org.springframework.data.r2dbc.core.ReactiveSelectOperation; import org.springframework.data.r2dbc.repository.R2dbcRepository; import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; import org.springframework.data.relational.core.query.Criteria; import org.springframework.data.relational.core.query.Query; import org.springframework.data.relational.repository.query.RelationalEntityInformation; import org.springframework.data.relational.repository.query.RelationalExampleMapper; +import org.springframework.data.repository.query.FluentQuery; import org.springframework.data.repository.reactive.ReactiveSortingRepository; import org.springframework.data.util.Lazy; import org.springframework.data.util.Streamable; @@ -432,6 +439,16 @@ public Mono exists(Example example) { return this.entityOperations.exists(query, example.getProbeType()); } + @Override + public > P findBy(Example example, + Function, P> queryFunction) { + + Assert.notNull(example, "Sample must not be null!"); + Assert.notNull(queryFunction, "Query function must not be null!"); + + return queryFunction.apply(new ReactiveFluentQueryByExample<>(example, example.getProbeType())); + } + private RelationalPersistentProperty getIdProperty() { return this.idProperty.get(); } @@ -439,4 +456,113 @@ private RelationalPersistentProperty getIdProperty() { private Query getIdQuery(Object id) { return Query.query(Criteria.where(getIdProperty().getName()).is(id)); } + + /** + * {@link org.springframework.data.repository.query.FluentQuery.ReactiveFluentQuery} using {@link Example}. + * + * @author Mark Paluch + * @since 1.4 + */ + class ReactiveFluentQueryByExample extends ReactiveFluentQuerySupport, T> { + + ReactiveFluentQueryByExample(Example example, Class resultType) { + this(example, Sort.unsorted(), resultType, Collections.emptyList()); + } + + ReactiveFluentQueryByExample(Example example, Sort sort, Class resultType, List fieldsToInclude) { + super(example, sort, resultType, fieldsToInclude); + } + + @Override + protected ReactiveFluentQueryByExample create(Example predicate, Sort sort, Class resultType, + List fieldsToInclude) { + return new ReactiveFluentQueryByExample<>(predicate, sort, resultType, fieldsToInclude); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.query.FluentQuery.ReactiveFluentQuery#one() + */ + @Override + public Mono one() { + return createQuery().one(); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.query.FluentQuery.ReactiveFluentQuery#first() + */ + @Override + public Mono first() { + return createQuery().first(); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.query.FluentQuery.ReactiveFluentQuery#all() + */ + @Override + public Flux all() { + return createQuery().all(); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.query.FluentQuery.ReactiveFluentQuery#page(org.springframework.data.domain.Pageable) + */ + @Override + public Mono> page(Pageable pageable) { + + Assert.notNull(pageable, "Pageable must not be null!"); + + Mono> items = createQuery(q -> q.with(pageable)).all().collectList(); + + return items.flatMap(content -> ReactivePageableExecutionUtils.getPage(content, pageable, this.count())); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.query.FluentQuery.ReactiveFluentQuery#count() + */ + @Override + public Mono count() { + return createQuery().count(); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.query.FluentQuery.ReactiveFluentQuery#exists() + */ + @Override + public Mono exists() { + return createQuery().exists(); + } + + private ReactiveSelectOperation.TerminatingSelect createQuery() { + return createQuery(UnaryOperator.identity()); + } + + @SuppressWarnings("unchecked") + private ReactiveSelectOperation.TerminatingSelect createQuery(UnaryOperator queryCustomizer) { + + Query query = exampleMapper.getMappedExample(getPredicate()); + + if (getSort().isSorted()) { + query = query.sort(getSort()); + } + + if (!getFieldsToInclude().isEmpty()) { + query = query.columns(getFieldsToInclude().toArray(new String[0])); + } + + query = queryCustomizer.apply(query); + + ReactiveSelectOperation.ReactiveSelect select = entityOperations.select(getPredicate().getProbeType()); + + if (getResultType() != getPredicate().getProbeType()) { + return select.as(getResultType()).matching(query); + } + return (ReactiveSelectOperation.TerminatingSelect) select.matching(query); + } + } } diff --git a/src/test/java/org/springframework/data/r2dbc/repository/support/AbstractSimpleR2dbcRepositoryIntegrationTests.java b/src/test/java/org/springframework/data/r2dbc/repository/support/AbstractSimpleR2dbcRepositoryIntegrationTests.java index 511e1b30..4ce470cd 100644 --- a/src/test/java/org/springframework/data/r2dbc/repository/support/AbstractSimpleR2dbcRepositoryIntegrationTests.java +++ b/src/test/java/org/springframework/data/r2dbc/repository/support/AbstractSimpleR2dbcRepositoryIntegrationTests.java @@ -38,10 +38,12 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.DataAccessException; +import org.springframework.dao.IncorrectResultSizeDataAccessException; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.data.annotation.Id; import org.springframework.data.annotation.Version; import org.springframework.data.domain.Example; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; import org.springframework.data.r2dbc.convert.MappingR2dbcConverter; import org.springframework.data.r2dbc.core.ReactiveDataAccessStrategy; @@ -51,6 +53,7 @@ import org.springframework.data.relational.core.mapping.Table; import org.springframework.data.relational.repository.query.RelationalEntityInformation; import org.springframework.data.relational.repository.support.MappingRelationalEntityInformation; +import org.springframework.data.repository.query.FluentQuery; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.r2dbc.core.DatabaseClient; @@ -771,6 +774,214 @@ void shouldCheckExistenceByExampleUsingId() { .verifyComplete(); } + @Test // GH-663 + void findByShouldReturnFirstResult() { + + jdbc.execute("INSERT INTO legoset (name, manual) VALUES('FORSCHUNGSSCHIFF', 13)"); + jdbc.execute("INSERT INTO legoset (name, manual) VALUES('SCHAUFELRADBAGGER', 12)"); + jdbc.execute("INSERT INTO legoset (name, manual) VALUES('VOLTRON', 15)"); + jdbc.execute("INSERT INTO legoset (name, manual) VALUES('RALLYEAUTO', 14)"); + + LegoSet probe = new LegoSet(); + probe.setName("FORSCHUNGSSCHIFF"); + + repository.findBy(Example.of(probe, matching().withIgnorePaths("id")), FluentQuery.ReactiveFluentQuery::first) // + .as(StepVerifier::create) // + .assertNext(actual -> { + + assertThat(actual.getManual()).isEqualTo(13); + }).verifyComplete(); + } + + @Test // GH-663 + void findByShouldReturnOneResult() { + + jdbc.execute("INSERT INTO legoset (name, manual) VALUES('FORSCHUNGSSCHIFF', 13)"); + jdbc.execute("INSERT INTO legoset (name, manual) VALUES('SCHAUFELRADBAGGER', 13)"); + jdbc.execute("INSERT INTO legoset (name, manual) VALUES('VOLTRON', 13)"); + jdbc.execute("INSERT INTO legoset (name, manual) VALUES('RALLYEAUTO', 14)"); + + LegoSet probe = new LegoSet(); + probe.setName("FORSCHUNGSSCHIFF"); + + repository.findBy(Example.of(probe, matching().withIgnorePaths("id")), FluentQuery.ReactiveFluentQuery::one) // + .as(StepVerifier::create) // + .assertNext(actual -> { + + assertThat(actual.getManual()).isEqualTo(13); + }).verifyComplete(); + + probe = new LegoSet(); + probe.setManual(13); + + repository.findBy(Example.of(probe, matching().withIgnorePaths("id")), FluentQuery.ReactiveFluentQuery::one) // + .as(StepVerifier::create) // + .verifyError(IncorrectResultSizeDataAccessException.class); + } + + @Test // GH-663 + void findByShouldReturnAll() { + + jdbc.execute("INSERT INTO legoset (name, manual) VALUES('FORSCHUNGSSCHIFF', 13)"); + jdbc.execute("INSERT INTO legoset (name, manual) VALUES('SCHAUFELRADBAGGER', 13)"); + jdbc.execute("INSERT INTO legoset (name, manual) VALUES('VOLTRON', 13)"); + jdbc.execute("INSERT INTO legoset (name, manual) VALUES('RALLYEAUTO', 14)"); + + LegoSet probe = new LegoSet(); + probe.setManual(13); + + repository.findBy(Example.of(probe, matching().withIgnorePaths("id")), FluentQuery.ReactiveFluentQuery::all) // + .as(StepVerifier::create) // + .expectNextCount(3) // + .verifyComplete(); + } + + @Test // GH-663 + void findByShouldApplySortAll() { + + jdbc.execute("INSERT INTO legoset (name, manual) VALUES('FORSCHUNGSSCHIFF', 13)"); + jdbc.execute("INSERT INTO legoset (name, manual) VALUES('SCHAUFELRADBAGGER', 13)"); + jdbc.execute("INSERT INTO legoset (name, manual) VALUES('VOLTRON', 13)"); + jdbc.execute("INSERT INTO legoset (name, manual) VALUES('RALLYEAUTO', 14)"); + + LegoSet probe = new LegoSet(); + probe.setManual(13); + + repository.findBy(Example.of(probe, matching().withIgnorePaths("id")), it -> it.sortBy(Sort.by("name")).all()) + .map(LegoSet::getName) // + .as(StepVerifier::create) // + .expectNext("FORSCHUNGSSCHIFF", "SCHAUFELRADBAGGER", "VOLTRON") // + .verifyComplete(); + + repository + .findBy(Example.of(probe, matching().withIgnorePaths("id")), + it -> it.sortBy(Sort.by(Sort.Direction.DESC, "name")).all()) + .map(LegoSet::getName) // + .as(StepVerifier::create) // + .expectNext("VOLTRON", "SCHAUFELRADBAGGER", "FORSCHUNGSSCHIFF") // + .verifyComplete(); + } + + @Test // GH-663 + void findByShouldApplyProjection() { + + jdbc.execute("INSERT INTO legoset (name, manual) VALUES('FORSCHUNGSSCHIFF', 13)"); + jdbc.execute("INSERT INTO legoset (name, manual) VALUES('SCHAUFELRADBAGGER', 13)"); + jdbc.execute("INSERT INTO legoset (name, manual) VALUES('VOLTRON', 13)"); + jdbc.execute("INSERT INTO legoset (name, manual) VALUES('RALLYEAUTO', 14)"); + + LegoSet probe = new LegoSet(); + probe.setName("FORSCHUNGSSCHIFF"); + + repository.findBy(Example.of(probe, matching().withIgnorePaths("id")), it -> it.project("name").first()) // + .as(StepVerifier::create) // + .assertNext(it -> { + + assertThat(it.getName()).isNotNull(); + assertThat(it.getManual()).isNull(); + }).verifyComplete(); + } + + @Test // GH-663 + void findByShouldApplyProjectionAs() { + + jdbc.execute("INSERT INTO legoset (name, manual) VALUES('FORSCHUNGSSCHIFF', 13)"); + jdbc.execute("INSERT INTO legoset (name, manual) VALUES('SCHAUFELRADBAGGER', 13)"); + jdbc.execute("INSERT INTO legoset (name, manual) VALUES('VOLTRON', 13)"); + jdbc.execute("INSERT INTO legoset (name, manual) VALUES('RALLYEAUTO', 14)"); + + LegoSet probe = new LegoSet(); + probe.setName("FORSCHUNGSSCHIFF"); + + repository.findBy(Example.of(probe, matching().withIgnorePaths("id")), it -> it.as(LegoSetProjection.class).first()) // + .as(StepVerifier::create) // + .assertNext(it -> { + + assertThat(it.getName()).isEqualTo("FORSCHUNGSSCHIFF"); + }).verifyComplete(); + } + + @Test // GH-663 + void findByShouldApplyPagination() { + + jdbc.execute("INSERT INTO legoset (name, manual) VALUES('FORSCHUNGSSCHIFF', 13)"); + jdbc.execute("INSERT INTO legoset (name, manual) VALUES('SCHAUFELRADBAGGER', 13)"); + jdbc.execute("INSERT INTO legoset (name, manual) VALUES('VOLTRON', 13)"); + jdbc.execute("INSERT INTO legoset (name, manual) VALUES('RALLYEAUTO', 14)"); + + LegoSet probe = new LegoSet(); + probe.setManual(13); + + repository + .findBy(Example.of(probe, matching().withIgnorePaths("id")), + it -> it.page(PageRequest.of(0, 1, Sort.by("name")))) // + .as(StepVerifier::create) // + .assertNext(it -> { + + assertThat(it.getTotalElements()).isEqualTo(3); + assertThat(it.getContent()).extracting(LegoSet::getName).containsOnly("FORSCHUNGSSCHIFF"); + }).verifyComplete(); + + repository + .findBy(Example.of(probe, matching().withIgnorePaths("id")), + it -> it.page(PageRequest.of(1, 1, Sort.by("name")))) // + .as(StepVerifier::create) // + .assertNext(it -> { + + assertThat(it.getTotalElements()).isEqualTo(3); + assertThat(it.getContent()).extracting(LegoSet::getName).containsOnly("SCHAUFELRADBAGGER"); + }).verifyComplete(); + } + + @Test // GH-663 + void findByShouldCount() { + + jdbc.execute("INSERT INTO legoset (name, manual) VALUES('FORSCHUNGSSCHIFF', 13)"); + jdbc.execute("INSERT INTO legoset (name, manual) VALUES('SCHAUFELRADBAGGER', 13)"); + jdbc.execute("INSERT INTO legoset (name, manual) VALUES('VOLTRON', 13)"); + jdbc.execute("INSERT INTO legoset (name, manual) VALUES('RALLYEAUTO', 14)"); + + LegoSet probe = new LegoSet(); + probe.setManual(13); + + repository.findBy(Example.of(probe, matching().withIgnorePaths("id")), FluentQuery.ReactiveFluentQuery::count) // + .as(StepVerifier::create) // + .expectNext(3L) // + .verifyComplete(); + + probe = new LegoSet(); + probe.setManual(0); + + repository.findBy(Example.of(probe, matching().withIgnorePaths("id")), FluentQuery.ReactiveFluentQuery::count) // + .as(StepVerifier::create) // + .expectNext(0L) // + .verifyComplete(); + } + + @Test // GH-663 + void findByShouldReportExists() { + + jdbc.execute("INSERT INTO legoset (name, manual) VALUES('SCHAUFELRADBAGGER', 13)"); + jdbc.execute("INSERT INTO legoset (name, manual) VALUES('VOLTRON', 13)"); + jdbc.execute("INSERT INTO legoset (name, manual) VALUES('RALLYEAUTO', 14)"); + + LegoSet probe = new LegoSet(); + probe.setManual(13); + + repository.findBy(Example.of(probe, matching().withIgnorePaths("id")), FluentQuery.ReactiveFluentQuery::exists) // + .as(StepVerifier::create) // + .expectNext(true) // + .verifyComplete(); + + probe = new LegoSet(); + probe.setManual(0); + + repository.findBy(Example.of(probe, matching().withIgnorePaths("id")), FluentQuery.ReactiveFluentQuery::exists) // + .as(StepVerifier::create) // + .expectNext(false) // + .verifyComplete(); + } + @Data @Table("legoset") @AllArgsConstructor @@ -782,6 +993,10 @@ static class LegoSet { Integer manual; } + interface LegoSetProjection { + String getName(); + } + @Data @Table("legoset") @AllArgsConstructor