Skip to content

Commit

Permalink
Add Spring Data MongoDB querying utilities (#454)
Browse files Browse the repository at this point in the history
* 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
sleberknight and chrisrohr authored Nov 24, 2020
1 parent edbbe33 commit 3a49369
Show file tree
Hide file tree
Showing 9 changed files with 1,522 additions and 0 deletions.
1 change: 1 addition & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
<sonar.projectKey>kiwiproject_kiwi</sonar.projectKey>
<sonar.organization>kiwiproject</sonar.organization>
<sonar.host.url>https://sonarcloud.io</sonar.host.url>
<sonar.coverage.exclusions>org/kiwiproject/spring/data/PagingQuery.java</sonar.coverage.exclusions>
</properties>

<dependencies>
Expand Down
36 changes: 36 additions & 0 deletions src/main/java/org/kiwiproject/spring/data/AggregateResult.java
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 src/main/java/org/kiwiproject/spring/data/KiwiSpringMongoQueries.java
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));
}

}
Loading

0 comments on commit 3a49369

Please sign in to comment.