Skip to content

Commit

Permalink
Add support for specifiying join type for projections and restrictions
Browse files Browse the repository at this point in the history
  • Loading branch information
agrancaric committed Jan 24, 2024
1 parent 279566d commit 26c66c0
Show file tree
Hide file tree
Showing 9 changed files with 208 additions and 64 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import net.croz.nrich.search.api.model.property.SearchPropertyMapping;
import net.croz.nrich.search.api.model.subquery.SubqueryConfiguration;

import jakarta.persistence.criteria.JoinType;
import java.util.List;
import java.util.function.Function;

Expand Down Expand Up @@ -103,6 +104,11 @@ public class SearchConfiguration<T, P, R> {
*/
private boolean anyMatch;

/**
* Default join type to used for projections and conditions. If not specified default is JoinType.INNER
*/
private JoinType defaultJoinType;

@Builder.Default
private SearchPropertyConfiguration searchPropertyConfiguration = SearchPropertyConfiguration.defaultSearchPropertyConfiguration();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ public class SearchJoin<R> {
private String alias;

/**
* Type of join (inner or left).
* Type of join.
*/
private JoinType joinType;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
import lombok.Setter;
import net.croz.nrich.search.api.model.property.SearchPropertyJoin;

import jakarta.persistence.criteria.JoinType;

/**
* Configuration for subquery. Allows specifying custom root entity, joins and resolving property values from
* search request either by property prefix or by a separate class holding all subquery restrictions.
Expand Down Expand Up @@ -51,4 +53,9 @@ public class SubqueryConfiguration {
*/
private String restrictionPropertyHolder;

/**
* Default join type to used for conditions. If not specified default is JoinType.INNER
*/
private JoinType defaultJoinType;

}
5 changes: 5 additions & 0 deletions nrich-search/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,11 @@ association to `Role`. It is possible to map that connection by using `UserRole`

This configuration will search `UserRole` entity by all properties in `UserSearchRequest` that have a prefix `userRole`.

#### Default join type

[`SearchConfiguration`][search-configuration-url] supports matching specifying default join type for conditions and projections. If none is specified then inner join is used.
This can be also customized for individual associations by specifying a `SearchJoin` with `joinType`, specified `joinType` will then be used when building projections and conditions.

[//]: # (Reference links for readability)

[search-configuration-url]: ../nrich-search-api/src/main/java/net/croz/nrich/search/api/model/SearchConfiguration.java
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@
import jakarta.persistence.criteria.Subquery;
import jakarta.persistence.metamodel.ManagedType;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
Expand Down Expand Up @@ -91,16 +90,16 @@ public <R, P> CriteriaQuery<P> buildQuery(R request, SearchConfiguration<T, P, R
searchProjectionList = ProjectionListResolverUtil.resolveSearchProjectionList(resultClass);
}

List<Selection<?>> projectionList = resolveQueryProjectionList(root, searchProjectionList, request);
resolveAndApplyPredicateList(request, searchConfiguration, criteriaBuilder, root, query);

List<Selection<?>> projectionList = resolveQueryProjectionList(searchConfiguration.getDefaultJoinType(), root, searchProjectionList, request);

if (!CollectionUtils.isEmpty(projectionList)) {
query.multiselect(projectionList);
}

query.distinct(searchConfiguration.isDistinct());

resolveAndApplyPredicateList(request, searchConfiguration, criteriaBuilder, root, query);

if (sort != null && sort.isSorted()) {
query.orderBy(QueryUtils.toOrders(sort, root, criteriaBuilder));
}
Expand Down Expand Up @@ -193,14 +192,14 @@ private <R> void applyJoinsOrFetchesToQuery(boolean applyFetch, R request, Root<
.forEach(searchJoin -> applyJoinOrJoinFetch(existingFetches, existingJoins, root, searchJoin, applyFetch));
}

private <R> List<Selection<?>> resolveQueryProjectionList(Root<?> root, List<SearchProjection<R>> projectionList, R request) {
private <R> List<Selection<?>> resolveQueryProjectionList(JoinType defaultJoinType, Root<?> root, List<SearchProjection<R>> projectionList, R request) {
if (CollectionUtils.isEmpty(projectionList)) {
return Collections.emptyList();
}

return projectionList.stream()
.filter(projection -> shouldApplyProjection(projection, request))
.map(projection -> convertToSelectionExpression(root, projection))
.map(projection -> convertToSelectionExpression(defaultJoinType, root, projection))
.collect(Collectors.toList());
}

Expand Down Expand Up @@ -247,21 +246,21 @@ private <P, R> void resolveAndApplyPredicateList(R request, SearchConfiguration<
}

private <P, R> List<Predicate> resolveQueryPredicateList(R request, SearchConfiguration<T, P, R> searchConfiguration, CriteriaBuilder criteriaBuilder, Root<?> root, CriteriaQuery<?> query) {
JoinType defaultJoinType = searchConfiguration.getDefaultJoinType();
Set<Restriction> restrictionList = new SearchDataParser(root.getModel(), request, SearchDataParserConfiguration.fromSearchConfiguration(searchConfiguration)).resolveRestrictionList();

Map<Boolean, List<Restriction>> restrictionsByType = restrictionList.stream().collect(Collectors.partitioningBy(Restriction::isPluralAttribute));

List<Predicate> mainQueryPredicateList = convertRestrictionListToPredicateList(restrictionsByType.get(false), root, criteriaBuilder);
List<Predicate> mainQueryPredicateList = convertRestrictionListToPredicateList(defaultJoinType, restrictionsByType.get(false), root, criteriaBuilder);

List<Restriction> pluralRestrictionList = restrictionsByType.get(true);
if (!CollectionUtils.isEmpty(pluralRestrictionList)) {

if (searchConfiguration.getPluralAssociationRestrictionType() == PluralAssociationRestrictionType.JOIN) {
mainQueryPredicateList.addAll(convertRestrictionListToPredicateList(pluralRestrictionList, root, criteriaBuilder));
mainQueryPredicateList.addAll(convertRestrictionListToPredicateList(defaultJoinType, pluralRestrictionList, root, criteriaBuilder));
}
else {
SearchPropertyJoin searchPropertyJoin = resolveSearchPropertyJoin(root);
Subquery<Integer> subquery = createSubqueryRestriction(root.getJavaType(), root, query, criteriaBuilder, pluralRestrictionList, searchPropertyJoin);
Subquery<Integer> subquery = createSubqueryRestriction(defaultJoinType, root.getJavaType(), root, query, criteriaBuilder, pluralRestrictionList, searchPropertyJoin);

mainQueryPredicateList.add(criteriaBuilder.exists(subquery));
}
Expand All @@ -274,39 +273,34 @@ private <P, R> List<Predicate> resolveQueryPredicateList(R request, SearchConfig
return mainQueryPredicateList;
}

private Subquery<Integer> createSubqueryRestriction(Class<?> subqueryEntityType, Root<?> parent, CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder, Collection<Restriction> restrictionList, SearchPropertyJoin searchPropertyJoin) {
private Subquery<Integer> createSubqueryRestriction(
JoinType defaultJoinType, Class<?> subqueryEntityType, Root<?> parent, CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder,
Collection<Restriction> restrictionList, SearchPropertyJoin searchPropertyJoin
) {
Subquery<Integer> subquery = query.subquery(Integer.class);
Root<?> subqueryRoot = subquery.from(subqueryEntityType);

subquery.select(criteriaBuilder.literal(1));

List<Predicate> subQueryPredicateList = convertRestrictionListToPredicateList(restrictionList, subqueryRoot, criteriaBuilder);
List<Predicate> subQueryPredicateList = convertRestrictionListToPredicateList(defaultJoinType, restrictionList, subqueryRoot, criteriaBuilder);

Path<?> parentPath = PathResolvingUtil.calculateFullRestrictionPath(parent, PathResolvingUtil.convertToPathList(searchPropertyJoin.getParentProperty()));
Path<?> subqueryPath = PathResolvingUtil.calculateFullRestrictionPath(subqueryRoot, PathResolvingUtil.convertToPathList(searchPropertyJoin.getChildProperty()));
Path<?> parentPath = PathResolvingUtil.calculateFullPath(parent, defaultJoinType, PathResolvingUtil.convertToPathList(searchPropertyJoin.getParentProperty()));
Path<?> subqueryPath = PathResolvingUtil.calculateFullPath(subqueryRoot, defaultJoinType, PathResolvingUtil.convertToPathList(searchPropertyJoin.getChildProperty()));

subQueryPredicateList.add(criteriaBuilder.equal(parentPath, subqueryPath));

return subquery.where(subQueryPredicateList.toArray(new Predicate[0]));
}

private List<Predicate> convertRestrictionListToPredicateList(Collection<Restriction> restrictionList, Root<?> rootPath, CriteriaBuilder criteriaBuilder) {
private List<Predicate> convertRestrictionListToPredicateList(JoinType joinType, Collection<Restriction> restrictionList, Root<?> rootPath, CriteriaBuilder criteriaBuilder) {
List<Predicate> predicateList = new ArrayList<>();

restrictionList.stream().filter(Objects::nonNull).forEach(restriction -> {
String[] pathList = PathResolvingUtil.convertToPathList(restriction.getPath());

if (restriction.isPluralAttribute()) {
String[] pluralAttributePathList = Arrays.copyOfRange(pathList, 1, pathList.length);
Path<?> fullPath = PathResolvingUtil.calculateFullRestrictionPath(rootPath.join(pathList[0]), pluralAttributePathList);
Path<?> fullPath = PathResolvingUtil.calculateFullPath(rootPath, joinType, pathList);

predicateList.add(restriction.getSearchOperator().asPredicate(criteriaBuilder, fullPath, restriction.getValue()));
}
else {
Path<?> fullPath = PathResolvingUtil.calculateFullRestrictionPath(rootPath, pathList);

predicateList.add(restriction.getSearchOperator().asPredicate(criteriaBuilder, fullPath, restriction.getValue()));
}
predicateList.add(restriction.getSearchOperator().asPredicate(criteriaBuilder, fullPath, restriction.getValue()));
});

return predicateList;
Expand All @@ -327,7 +321,9 @@ private <R> List<Subquery<?>> resolveSubqueryList(R request, SearchPropertyConfi
return subqueryConfigurationList.stream().map(subqueryConfiguration -> buildSubquery(request, searchPropertyConfiguration, root, query, criteriaBuilder, subqueryConfiguration)).filter(Objects::nonNull).collect(Collectors.toList());
}

private <R> Subquery<Integer> buildSubquery(R request, SearchPropertyConfiguration searchPropertyConfiguration, Root<?> root, CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder, SubqueryConfiguration subqueryConfiguration) {
private <R> Subquery<Integer> buildSubquery(
R request, SearchPropertyConfiguration searchPropertyConfiguration, Root<?> root, CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder, SubqueryConfiguration subqueryConfiguration
) {
ManagedType<?> subqueryRoot = entityManager.getMetamodel().managedType(subqueryConfiguration.getRootEntity());

Set<Restriction> subqueryRestrictionList;
Expand All @@ -346,16 +342,18 @@ private <R> Subquery<Integer> buildSubquery(R request, SearchPropertyConfigurati

Subquery<Integer> subquery = null;
if (!CollectionUtils.isEmpty(subqueryRestrictionList)) {
subquery = createSubqueryRestriction(subqueryConfiguration.getRootEntity(), root, query, criteriaBuilder, subqueryRestrictionList, subqueryConfiguration.getJoinBy());
subquery = createSubqueryRestriction(
subqueryConfiguration.getDefaultJoinType(), subqueryConfiguration.getRootEntity(), root, query, criteriaBuilder, subqueryRestrictionList, subqueryConfiguration.getJoinBy()
);
}

return subquery;
}

private <R> Selection<?> convertToSelectionExpression(Path<?> root, SearchProjection<R> projection) {
private <R> Selection<?> convertToSelectionExpression(JoinType defaultJoinType, Root<?> root, SearchProjection<R> projection) {
String[] pathList = PathResolvingUtil.convertToPathList(projection.getPath());

Path<?> path = PathResolvingUtil.calculateFullSelectionPath(root, pathList);
Path<?> path = PathResolvingUtil.calculateFullPath(root, defaultJoinType, pathList);

String alias = projection.getAlias() == null ? pathList[pathList.length - 1] : projection.getAlias();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,24 @@
package net.croz.nrich.search.util;

import jakarta.persistence.criteria.From;
import jakarta.persistence.criteria.JoinType;
import jakarta.persistence.criteria.Path;
import jakarta.persistence.metamodel.Attribute;
import jakarta.persistence.metamodel.EntityType;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;

public final class PathResolvingUtil {

private static final String PATH_SEPARATOR = ".";

private static final String PATH_REGEX = "\\.";

// Hibernate will throw an exception if trying to find as an attribute but it can be used as an condition and as an projection
private static final String CLASS_ATTRIBUTE_NAME = "class";

private PathResolvingUtil() {
}

Expand All @@ -52,28 +59,38 @@ public static String removeFirstPathElement(String[] path) {
return String.join(PATH_SEPARATOR, Arrays.copyOfRange(path, 1, path.length));
}


public static Path<?> calculateFullRestrictionPath(Path<?> rootPath, String[] pathList) {
return calculateFullPath(rootPath, pathList, false);
}

public static Path<?> calculateFullSelectionPath(Path<?> rootPath, String[] pathList) {
return calculateFullPath(rootPath, pathList, true);
}

private static Path<?> calculateFullPath(Path<?> rootPath, String[] pathList, boolean isSelection) {
int lastElementIndex = pathList.length - 1;
public static Path<?> calculateFullPath(Path<?> rootPath, JoinType defaultJoinType, String[] pathList) {
JoinType calculatedJoinPath = Optional.ofNullable(defaultJoinType).orElse(JoinType.INNER);
Path<?> calculatedPath = rootPath;

for (int i = 0; i < pathList.length; i++) {
if (isSelection || i == lastElementIndex) {
calculatedPath = calculatedPath.get(pathList[i]);
for (String currentPathSegment : pathList) {
if (shouldJoinPath(calculatedPath, currentPathSegment)) {
From<?, ?> from = (From<?, ?>) calculatedPath;

calculatedPath = from.getJoins().stream()
.filter(join -> currentPathSegment.equals(join.getAttribute().getName()))
.findFirst()
.orElseGet(() -> from.join(currentPathSegment, calculatedJoinPath));
}
else {
calculatedPath = ((From<?, ?>) calculatedPath).join(pathList[i]);
calculatedPath = calculatedPath.get(currentPathSegment);
}
}

return calculatedPath;
}

private static boolean shouldJoinPath(Path<?> calculatedPath, String currentPathSegment) {
if (CLASS_ATTRIBUTE_NAME.equals(currentPathSegment)) {
return false;
}

if (calculatedPath.getModel() instanceof EntityType<?> entityType) {
Attribute<?, ?> attribute = entityType.getAttribute(currentPathSegment);

return attribute.isCollection() || attribute.isAssociation();
}

return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
import jakarta.persistence.PersistenceContext;
import jakarta.persistence.Tuple;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.JoinType;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
Expand Down Expand Up @@ -795,21 +796,43 @@ void shouldSearchByNestedCollectionProperties() {
void shouldNotAddAdditionalJoinsForSelection() {
// given
TestEntitySearchRequest request = TestEntitySearchRequest.builder()
.nestedEntityNestedEntityName("name")
.build();

SearchProjection<TestEntitySearchRequest> nameProjection = new SearchProjection<>("name");
SearchProjection<TestEntitySearchRequest> nestedNameProjection = SearchProjection.<TestEntitySearchRequest>builder().path("nestedEntity.nestedEntityName").alias("nestedName").build();

SearchConfiguration<TestEntity, TestEntityDto, TestEntitySearchRequest> searchConfiguration = SearchConfiguration.<TestEntity, TestEntityDto, TestEntitySearchRequest>builder()
.resultClass(TestEntityDto.class)
.resolvePropertyMappingUsingPrefix(true)
.projectionList(Arrays.asList(nameProjection, nestedNameProjection))
.build();

// when
CriteriaQuery<TestEntityDto> result = jpaQueryBuilder.buildQuery(request, searchConfiguration, Sort.unsorted());

// then
assertThat(result.getRoots().iterator().next().getJoins()).isEmpty();
assertThat(result.getRoots().iterator().next().getJoins()).hasSize(1);
}

@Test
void shouldJoinBySpecifiedDefaultJoinType() {
// given
TestEntitySearchRequest request = TestEntitySearchRequest.builder()
.nestedEntityNestedEntityName("name")
.build();

SearchConfiguration<TestEntity, TestEntity, TestEntitySearchRequest> searchConfiguration = SearchConfiguration.<TestEntity, TestEntity, TestEntitySearchRequest>builder()
.defaultJoinType(JoinType.LEFT)
.resolvePropertyMappingUsingPrefix(true)
.build();

// when
CriteriaQuery<TestEntity> result = jpaQueryBuilder.buildQuery(request, searchConfiguration, Sort.unsorted());

// then
assertThat(result.getRoots().iterator().next().getJoins()).hasSize(1);
assertThat(result.getRoots().iterator().next().getJoins()).extracting("joinType").containsOnly(JoinType.LEFT);
}

@Test
Expand Down
Loading

0 comments on commit 26c66c0

Please sign in to comment.