-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add Spring Data MongoDB querying utilities (#454)
* Add Spring Data MongoDB querying utilities * Add KiwiSpringMongoQueries which contains static utilities * Add PagingQuery and companion class AggregateResult * Marked PagingQuery#aggregatePage and AggregateResult with the @beta annotation and put all kinds of caveats and potential issues in the docs. * Note that the KiwiSpringMongoQueriesTest uses the in-memory de.bwaldvogel.mongo.MongoServer while PagingQueryAggregatePageTest requires a "real" MongoDB instance to run. For whatever reason the in-memory MongoServer blows up with an NPE during the aggregation and I don't know if the fault is with it or with our code. So, for now the test only runs if several system properties are set that tell it to use a "real" MongoDB and where that server is running. Fixes #415 Fixes #416 * Exclude PagingQuery from Sonar code coverage * Exclude PagingQuery from Sonar code coverage, second try * Update AggregateResultTest.java Co-authored-by: Chris Rohr <51920+chrisrohr@users.noreply.github.com>
- Loading branch information
1 parent
edbbe33
commit 3a49369
Showing
9 changed files
with
1,522 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
36 changes: 36 additions & 0 deletions
36
src/main/java/org/kiwiproject/spring/data/AggregateResult.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
package org.kiwiproject.spring.data; | ||
|
||
import com.google.common.annotations.Beta; | ||
import lombok.Getter; | ||
import lombok.Setter; | ||
import org.springframework.data.mongodb.core.aggregation.AggregationOperation; | ||
|
||
import java.util.List; | ||
|
||
/** | ||
* A generic aggregate result containing a list of results, and a total count. | ||
* | ||
* @param <T> the content type contained in this aggregate result | ||
* @implNote Marked as beta because it is used by {@link PagingQuery#aggregatePage(Class, AggregationOperation...)}. | ||
* Read the docs there for an explanation why that is beta. | ||
*/ | ||
@Getter | ||
@Setter | ||
@Beta | ||
public class AggregateResult<T> { | ||
|
||
private List<T> results; | ||
private long totalCount; | ||
|
||
/** | ||
* Factory to create {@link AggregateResult} instances of a given type. | ||
* | ||
* @param clazz the Class representing the result type | ||
* @param <T> the result type | ||
* @return a new instance | ||
*/ | ||
@SuppressWarnings("unused") | ||
public static <T> AggregateResult<T> of(Class<T> clazz) { | ||
return new AggregateResult<>(); | ||
} | ||
} |
302 changes: 302 additions & 0 deletions
302
src/main/java/org/kiwiproject/spring/data/KiwiSpringMongoQueries.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,302 @@ | ||
package org.kiwiproject.spring.data; | ||
|
||
import static java.util.Objects.isNull; | ||
import static java.util.Objects.nonNull; | ||
import static java.util.stream.Collectors.toList; | ||
import static org.apache.commons.lang3.StringUtils.isBlank; | ||
import static org.kiwiproject.base.KiwiPreconditions.checkArgumentNotNull; | ||
|
||
import lombok.experimental.UtilityClass; | ||
import lombok.extern.slf4j.Slf4j; | ||
import org.apache.commons.lang3.BooleanUtils; | ||
import org.kiwiproject.base.KiwiStrings; | ||
import org.kiwiproject.util.function.KiwiBiConsumers; | ||
import org.springframework.data.domain.Page; | ||
import org.springframework.data.mongodb.core.MongoTemplate; | ||
import org.springframework.data.mongodb.core.query.Criteria; | ||
import org.springframework.data.mongodb.core.query.Query; | ||
|
||
import javax.annotation.Nullable; | ||
import java.util.Collection; | ||
import java.util.Date; | ||
import java.util.function.BiConsumer; | ||
import java.util.function.Function; | ||
|
||
/** | ||
* Static utilities for performing MongoDB queries using Spring Data. | ||
*/ | ||
@UtilityClass | ||
@Slf4j | ||
public class KiwiSpringMongoQueries { | ||
|
||
private static final String ANY_STRING = ".*"; | ||
private static final String CASE_INSENSITIVE_OPTION = "i"; | ||
|
||
/** | ||
* Paginate objects of the given class, which are assumed to be mapped to a Mongo collection, using the given | ||
* paging parameters. | ||
* | ||
* @param mongoTemplate the {@link MongoTemplate} that is used to perform the MongoDB operations | ||
* @param pagingParams the parameters describing the desired pagination | ||
* @param clazz the domain/model class mapped to a Mongo collection | ||
* @param <T> the result type | ||
* @param <P> the pagination parameter type | ||
* @return a {@link Page} containing the paginated results | ||
*/ | ||
public static <T, P extends PagingParams> Page<T> paginate(MongoTemplate mongoTemplate, | ||
P pagingParams, | ||
Class<T> clazz) { | ||
|
||
return paginate(mongoTemplate, pagingParams, clazz, KiwiBiConsumers.noOp()); | ||
} | ||
|
||
/** | ||
* Paginate objects of the given class, which are assumed to be mapped to a Mongo collection, using the given | ||
* paging parameters. | ||
* <p> | ||
* The {@code criteriaBuilder} is a {@link BiConsumer} that can be used to specify restriction criteria and/or | ||
* to access or change the pagination parameters. | ||
* | ||
* @param mongoTemplate the {@link MongoTemplate} that is used to perform the MongoDB operations | ||
* @param pagingParams the parameters describing the desired pagination | ||
* @param clazz the domain/model class mapped to a Mongo collection | ||
* @param criteriaBuilder a {@link BiConsumer} that can be used to add additional pagination and query criteria | ||
* @param <T> the result type | ||
* @param <P> the pagination parameter type | ||
* @return a {@link Page} containing the paginated results, optionally filtered by criteria | ||
*/ | ||
public static <T, P extends PagingParams> Page<T> paginate(MongoTemplate mongoTemplate, | ||
P pagingParams, | ||
Class<T> clazz, | ||
BiConsumer<PagingQuery, P> criteriaBuilder) { | ||
|
||
checkArgumentNotNull(mongoTemplate); | ||
checkArgumentNotNull(pagingParams); | ||
checkArgumentNotNull(clazz); | ||
checkArgumentNotNull(criteriaBuilder); | ||
|
||
if (isNull(pagingParams.getLimit()) || pagingParams.getLimit() < 1) { | ||
LOG.warn("No limit was supplied; setting it to 1. Supply a limit to avoid this warning"); | ||
pagingParams.setLimit(1); | ||
} | ||
|
||
if (isNull(pagingParams.getPage()) || pagingParams.getPage() < 0) { | ||
LOG.warn("No page number was supplied; setting it to 0. Supply a page number to avoid this warning"); | ||
pagingParams.setPage(0); | ||
} | ||
|
||
LOG.debug("Performing search using params: {}", pagingParams); | ||
var pageable = KiwiPaging.createPageable(pagingParams); | ||
var query = new PagingQuery(mongoTemplate).with(pageable); | ||
criteriaBuilder.accept(query, pagingParams); | ||
|
||
LOG.debug("Executing query: {}", query); | ||
return query.findPage(clazz); | ||
} | ||
|
||
/** | ||
* Add date restrictions to the given property. | ||
* <p> | ||
* Specify both start and end milliseconds (since the epoch) to create a closed range, or specify only start or | ||
* end milliseconds to create an open-range. E.g. if only start milliseconds is specified, then the criteria | ||
* includes only dates that are equal to or after the given value, with no upper bound. | ||
* <p> | ||
* If both start and end milliseconds are null, the call is a no-op. | ||
* | ||
* @param query the MongoDB query on which to add the criteria | ||
* @param propertyName the property name, which is expected to be of type {@link Date} | ||
* @param startDateInclusiveMillis the start date, inclusive. May be null. | ||
* @param endDateInclusiveMillis the end date, inclusive. May be null. | ||
*/ | ||
public static void addDateBounds(Query query, | ||
String propertyName, | ||
@Nullable Long startDateInclusiveMillis, | ||
@Nullable Long endDateInclusiveMillis) { | ||
|
||
checkArgumentNotNull(query); | ||
checkArgumentNotNull(propertyName); | ||
|
||
if (isNull(startDateInclusiveMillis) && isNull(endDateInclusiveMillis)) { | ||
LOG.info("start and end are both null; ignoring"); | ||
return; | ||
} | ||
|
||
checkArgumentNotNull(propertyName, "property must not be null"); | ||
|
||
var datePropertyCriteria = Criteria.where(propertyName); | ||
|
||
// lower date bound | ||
if (nonNull(startDateInclusiveMillis)) { | ||
datePropertyCriteria.gte(new Date(startDateInclusiveMillis)); | ||
} | ||
|
||
// upper date bound | ||
if (nonNull(endDateInclusiveMillis)) { | ||
datePropertyCriteria.lte(new Date(endDateInclusiveMillis)); | ||
} | ||
|
||
query.addCriteria(datePropertyCriteria); | ||
} | ||
|
||
/** | ||
* Defines whether to require a partial or exact match. | ||
*/ | ||
public enum PartialMatchType { | ||
|
||
/** | ||
* Permits regex matching in a case-insensitive manner. | ||
* | ||
* @see Criteria#regex(String) | ||
*/ | ||
PARTIAL_MATCH, | ||
|
||
/** | ||
* Requires an equal match, and is case-sensitive. | ||
* | ||
* @see Criteria#is(Object) | ||
*/ | ||
EQUAL_MATCH; | ||
|
||
/** | ||
* Convert the given string into a {@link PartialMatchType}, where "truthy" values are considered to | ||
* represent {@link #PARTIAL_MATCH}, and "falsy" values are considered to mean {@link #EQUAL_MATCH}. | ||
* <p> | ||
* Accepts various values, such as "true", "false", "yes", "no", etc. and null is treated as false. | ||
* | ||
* @param value the value to convert | ||
* @return the {@link PartialMatchType} | ||
* @implNote Uses {@link BooleanUtils#toString()} to perform the conversion | ||
*/ | ||
public static PartialMatchType fromBooleanString(String value) { | ||
var allowPartialMatch = BooleanUtils.toBoolean(value); | ||
return from(allowPartialMatch); | ||
} | ||
|
||
/** | ||
* Convert the given boolean into a {@link PartialMatchType}. True is converted to {@link #PARTIAL_MATCH} | ||
* and false is converted to {@link #EQUAL_MATCH}. | ||
* | ||
* @param value the value to convert | ||
* @return the {@link PartialMatchType} | ||
*/ | ||
public static PartialMatchType from(boolean value) { | ||
if (value) { | ||
return PARTIAL_MATCH; | ||
} | ||
|
||
return EQUAL_MATCH; | ||
} | ||
} | ||
|
||
/** | ||
* Add a partial or equal match criteria for the given property and match string. | ||
* | ||
* @param query the MongoDB query on which to add the criteria | ||
* @param matchString the string to match | ||
* @param propertyName the property name | ||
* @param matchType the desired match type | ||
*/ | ||
public static void addPartialOrEqualMatchCriteria(Query query, | ||
String matchString, | ||
String propertyName, | ||
PartialMatchType matchType) { | ||
|
||
checkArgumentNotNull(query); | ||
checkArgumentNotNull(propertyName); | ||
checkArgumentNotNull(matchType); | ||
|
||
if (isBlank(matchString)) { | ||
LOG.info("matchString is blank; ignoring"); | ||
return; | ||
} | ||
|
||
Criteria matchCriteria; | ||
|
||
if (matchType == PartialMatchType.PARTIAL_MATCH) { | ||
matchCriteria = Criteria.where(propertyName).regex(ANY_STRING + matchString + ANY_STRING, CASE_INSENSITIVE_OPTION); | ||
} else { | ||
matchCriteria = Criteria.where(propertyName).is(matchString); | ||
} | ||
|
||
query.addCriteria(matchCriteria); | ||
} | ||
|
||
/** | ||
* Add a partial or equal match criteria for the given property and match strings. Any of the match strings | ||
* are considered to be a match, i.e. this effectively performs an <em>OR</em> operation. | ||
* | ||
* @param query the MongoDB query on which to add the criteria | ||
* @param matchStrings the strings to match, using an <em>OR</em> operation | ||
* @param propertyName the property name | ||
* @param matchType the desired match type | ||
*/ | ||
public static void addMultiplePartialOrEqualMatchCriteria(Query query, | ||
Collection<String> matchStrings, | ||
String propertyName, | ||
PartialMatchType matchType) { | ||
|
||
checkArgumentNotNull(query); | ||
checkArgumentNotNull(propertyName); | ||
checkArgumentNotNull(matchType); | ||
|
||
if (isNull(matchStrings) || matchStrings.isEmpty()) { | ||
LOG.info("matchStrings is null or empty; ignoring"); | ||
return; | ||
} | ||
|
||
Criteria matchCriteria; | ||
|
||
if (matchType == PartialMatchType.PARTIAL_MATCH) { | ||
var termCriteria = matchStrings | ||
.stream() | ||
.map(term -> Criteria.where(propertyName).regex(ANY_STRING + term + ANY_STRING, CASE_INSENSITIVE_OPTION)) | ||
.toArray(Criteria[]::new); | ||
matchCriteria = new Criteria().orOperator(termCriteria); | ||
} else { | ||
matchCriteria = Criteria.where(propertyName).in(matchStrings); | ||
} | ||
|
||
query.addCriteria(matchCriteria); | ||
} | ||
|
||
/** | ||
* Adds a {@link Criteria#in(Object...)} for the given property using values separated by commas in {@code csv}. | ||
* | ||
* @param query the MongoDB query on which to add the criteria | ||
* @param csv a comma-separated list of acceptable values | ||
* @param propertyName the property name | ||
*/ | ||
public static void addInCriteriaFromCsv(Query query, String csv, String propertyName) { | ||
addInCriteriaFromCsv(query, csv, propertyName, Function.identity()); | ||
} | ||
|
||
/** | ||
* Adds a {@link Criteria#in(Object...)} for the given property using by first separating the values by comma in | ||
* {@code csv}, and then applying the given function to each value. | ||
* | ||
* @param query the MongoDB query on which to add the criteria | ||
* @param csv a comma-separated list of acceptable values | ||
* @param propertyName the property name | ||
* @param converter a function to convert the separated strings into a different type | ||
* @param <T> the result type | ||
*/ | ||
public static <T> void addInCriteriaFromCsv(Query query, | ||
String csv, | ||
String propertyName, | ||
Function<String, T> converter) { | ||
|
||
checkArgumentNotNull(query); | ||
checkArgumentNotNull(propertyName); | ||
checkArgumentNotNull(converter); | ||
|
||
if (isBlank(csv)) { | ||
LOG.info("csv is blank; ignoring"); | ||
return; | ||
} | ||
|
||
var values = KiwiStrings.nullSafeSplitOnCommas(csv); | ||
var convertedValues = values.stream().map(converter).collect(toList()); | ||
query.addCriteria(Criteria.where(propertyName).in(convertedValues)); | ||
} | ||
|
||
} |
Oops, something went wrong.