From 26c66c0669e3e7406bcb83ec6b6a0dd93c2d15aa Mon Sep 17 00:00:00 2001 From: agrancaric Date: Fri, 19 Jan 2024 08:36:28 +0100 Subject: [PATCH] Add support for specifiying join type for projections and restrictions --- .../search/api/model/SearchConfiguration.java | 6 ++ .../nrich/search/api/model/SearchJoin.java | 2 +- .../model/subquery/SubqueryConfiguration.java | 7 ++ nrich-search/README.md | 5 ++ .../nrich/search/support/JpaQueryBuilder.java | 56 +++++++-------- .../nrich/search/util/PathResolvingUtil.java | 47 ++++++++---- .../support/JpaQueryBuilderTest.java | 25 ++++++- .../search/util/PathResolvingUtilTest.java | 52 +++++++++----- .../PathResolvingUtilGeneratingUtil.java | 72 +++++++++++++++++++ 9 files changed, 208 insertions(+), 64 deletions(-) create mode 100644 nrich-search/src/test/java/net/croz/nrich/search/util/testutil/PathResolvingUtilGeneratingUtil.java diff --git a/nrich-search-api/src/main/java/net/croz/nrich/search/api/model/SearchConfiguration.java b/nrich-search-api/src/main/java/net/croz/nrich/search/api/model/SearchConfiguration.java index da9ec1dfb..ea3b2227e 100644 --- a/nrich-search-api/src/main/java/net/croz/nrich/search/api/model/SearchConfiguration.java +++ b/nrich-search-api/src/main/java/net/croz/nrich/search/api/model/SearchConfiguration.java @@ -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; @@ -103,6 +104,11 @@ public class SearchConfiguration { */ 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(); diff --git a/nrich-search-api/src/main/java/net/croz/nrich/search/api/model/SearchJoin.java b/nrich-search-api/src/main/java/net/croz/nrich/search/api/model/SearchJoin.java index 1a2771e99..1ef7b1d1d 100644 --- a/nrich-search-api/src/main/java/net/croz/nrich/search/api/model/SearchJoin.java +++ b/nrich-search-api/src/main/java/net/croz/nrich/search/api/model/SearchJoin.java @@ -47,7 +47,7 @@ public class SearchJoin { private String alias; /** - * Type of join (inner or left). + * Type of join. */ private JoinType joinType; diff --git a/nrich-search-api/src/main/java/net/croz/nrich/search/api/model/subquery/SubqueryConfiguration.java b/nrich-search-api/src/main/java/net/croz/nrich/search/api/model/subquery/SubqueryConfiguration.java index 09226e362..0944c732a 100644 --- a/nrich-search-api/src/main/java/net/croz/nrich/search/api/model/subquery/SubqueryConfiguration.java +++ b/nrich-search-api/src/main/java/net/croz/nrich/search/api/model/subquery/SubqueryConfiguration.java @@ -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. @@ -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; + } diff --git a/nrich-search/README.md b/nrich-search/README.md index 265a977bd..230c81a22 100644 --- a/nrich-search/README.md +++ b/nrich-search/README.md @@ -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 diff --git a/nrich-search/src/main/java/net/croz/nrich/search/support/JpaQueryBuilder.java b/nrich-search/src/main/java/net/croz/nrich/search/support/JpaQueryBuilder.java index ec331b170..d94bbe63d 100644 --- a/nrich-search/src/main/java/net/croz/nrich/search/support/JpaQueryBuilder.java +++ b/nrich-search/src/main/java/net/croz/nrich/search/support/JpaQueryBuilder.java @@ -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; @@ -91,7 +90,9 @@ public CriteriaQuery

buildQuery(R request, SearchConfiguration> projectionList = resolveQueryProjectionList(root, searchProjectionList, request); + resolveAndApplyPredicateList(request, searchConfiguration, criteriaBuilder, root, query); + + List> projectionList = resolveQueryProjectionList(searchConfiguration.getDefaultJoinType(), root, searchProjectionList, request); if (!CollectionUtils.isEmpty(projectionList)) { query.multiselect(projectionList); @@ -99,8 +100,6 @@ public CriteriaQuery

buildQuery(R request, SearchConfiguration void applyJoinsOrFetchesToQuery(boolean applyFetch, R request, Root< .forEach(searchJoin -> applyJoinOrJoinFetch(existingFetches, existingJoins, root, searchJoin, applyFetch)); } - private List> resolveQueryProjectionList(Root root, List> projectionList, R request) { + private List> resolveQueryProjectionList(JoinType defaultJoinType, Root root, List> 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()); } @@ -247,21 +246,21 @@ private void resolveAndApplyPredicateList(R request, SearchConfiguration< } private List resolveQueryPredicateList(R request, SearchConfiguration searchConfiguration, CriteriaBuilder criteriaBuilder, Root root, CriteriaQuery query) { + JoinType defaultJoinType = searchConfiguration.getDefaultJoinType(); Set restrictionList = new SearchDataParser(root.getModel(), request, SearchDataParserConfiguration.fromSearchConfiguration(searchConfiguration)).resolveRestrictionList(); - Map> restrictionsByType = restrictionList.stream().collect(Collectors.partitioningBy(Restriction::isPluralAttribute)); - List mainQueryPredicateList = convertRestrictionListToPredicateList(restrictionsByType.get(false), root, criteriaBuilder); + List mainQueryPredicateList = convertRestrictionListToPredicateList(defaultJoinType, restrictionsByType.get(false), root, criteriaBuilder); List 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 subquery = createSubqueryRestriction(root.getJavaType(), root, query, criteriaBuilder, pluralRestrictionList, searchPropertyJoin); + Subquery subquery = createSubqueryRestriction(defaultJoinType, root.getJavaType(), root, query, criteriaBuilder, pluralRestrictionList, searchPropertyJoin); mainQueryPredicateList.add(criteriaBuilder.exists(subquery)); } @@ -274,39 +273,34 @@ private List resolveQueryPredicateList(R request, SearchConfig return mainQueryPredicateList; } - private Subquery createSubqueryRestriction(Class subqueryEntityType, Root parent, CriteriaQuery query, CriteriaBuilder criteriaBuilder, Collection restrictionList, SearchPropertyJoin searchPropertyJoin) { + private Subquery createSubqueryRestriction( + JoinType defaultJoinType, Class subqueryEntityType, Root parent, CriteriaQuery query, CriteriaBuilder criteriaBuilder, + Collection restrictionList, SearchPropertyJoin searchPropertyJoin + ) { Subquery subquery = query.subquery(Integer.class); Root subqueryRoot = subquery.from(subqueryEntityType); subquery.select(criteriaBuilder.literal(1)); - List subQueryPredicateList = convertRestrictionListToPredicateList(restrictionList, subqueryRoot, criteriaBuilder); + List 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 convertRestrictionListToPredicateList(Collection restrictionList, Root rootPath, CriteriaBuilder criteriaBuilder) { + private List convertRestrictionListToPredicateList(JoinType joinType, Collection restrictionList, Root rootPath, CriteriaBuilder criteriaBuilder) { List 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; @@ -327,7 +321,9 @@ private List> resolveSubqueryList(R request, SearchPropertyConfi return subqueryConfigurationList.stream().map(subqueryConfiguration -> buildSubquery(request, searchPropertyConfiguration, root, query, criteriaBuilder, subqueryConfiguration)).filter(Objects::nonNull).collect(Collectors.toList()); } - private Subquery buildSubquery(R request, SearchPropertyConfiguration searchPropertyConfiguration, Root root, CriteriaQuery query, CriteriaBuilder criteriaBuilder, SubqueryConfiguration subqueryConfiguration) { + private Subquery buildSubquery( + R request, SearchPropertyConfiguration searchPropertyConfiguration, Root root, CriteriaQuery query, CriteriaBuilder criteriaBuilder, SubqueryConfiguration subqueryConfiguration + ) { ManagedType subqueryRoot = entityManager.getMetamodel().managedType(subqueryConfiguration.getRootEntity()); Set subqueryRestrictionList; @@ -346,16 +342,18 @@ private Subquery buildSubquery(R request, SearchPropertyConfigurati Subquery 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 Selection convertToSelectionExpression(Path root, SearchProjection projection) { + private Selection convertToSelectionExpression(JoinType defaultJoinType, Root root, SearchProjection 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(); diff --git a/nrich-search/src/main/java/net/croz/nrich/search/util/PathResolvingUtil.java b/nrich-search/src/main/java/net/croz/nrich/search/util/PathResolvingUtil.java index d2f58520e..9e1391e62 100644 --- a/nrich-search/src/main/java/net/croz/nrich/search/util/PathResolvingUtil.java +++ b/nrich-search/src/main/java/net/croz/nrich/search/util/PathResolvingUtil.java @@ -18,10 +18,14 @@ 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 { @@ -29,6 +33,9 @@ public final class PathResolvingUtil { 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() { } @@ -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; + } } diff --git a/nrich-search/src/test/java/net/croz/nrich/search/repository/support/JpaQueryBuilderTest.java b/nrich-search/src/test/java/net/croz/nrich/search/repository/support/JpaQueryBuilderTest.java index f91442516..580528a96 100644 --- a/nrich-search/src/test/java/net/croz/nrich/search/repository/support/JpaQueryBuilderTest.java +++ b/nrich-search/src/test/java/net/croz/nrich/search/repository/support/JpaQueryBuilderTest.java @@ -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; @@ -795,6 +796,7 @@ void shouldSearchByNestedCollectionProperties() { void shouldNotAddAdditionalJoinsForSelection() { // given TestEntitySearchRequest request = TestEntitySearchRequest.builder() + .nestedEntityNestedEntityName("name") .build(); SearchProjection nameProjection = new SearchProjection<>("name"); @@ -802,6 +804,7 @@ void shouldNotAddAdditionalJoinsForSelection() { SearchConfiguration searchConfiguration = SearchConfiguration.builder() .resultClass(TestEntityDto.class) + .resolvePropertyMappingUsingPrefix(true) .projectionList(Arrays.asList(nameProjection, nestedNameProjection)) .build(); @@ -809,7 +812,27 @@ void shouldNotAddAdditionalJoinsForSelection() { CriteriaQuery 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 searchConfiguration = SearchConfiguration.builder() + .defaultJoinType(JoinType.LEFT) + .resolvePropertyMappingUsingPrefix(true) + .build(); + + // when + CriteriaQuery 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 diff --git a/nrich-search/src/test/java/net/croz/nrich/search/util/PathResolvingUtilTest.java b/nrich-search/src/test/java/net/croz/nrich/search/util/PathResolvingUtilTest.java index 2ffb6811b..bc3b079eb 100644 --- a/nrich-search/src/test/java/net/croz/nrich/search/util/PathResolvingUtilTest.java +++ b/nrich-search/src/test/java/net/croz/nrich/search/util/PathResolvingUtilTest.java @@ -18,13 +18,18 @@ package net.croz.nrich.search.util; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.NullSource; -import jakarta.persistence.criteria.From; -import jakarta.persistence.criteria.Join; +import jakarta.persistence.criteria.JoinType; import jakarta.persistence.criteria.Path; +import jakarta.persistence.criteria.Root; import java.util.Arrays; import java.util.List; +import static net.croz.nrich.search.util.testutil.PathResolvingUtilGeneratingUtil.createRootWithAssociationAttribute; +import static net.croz.nrich.search.util.testutil.PathResolvingUtilGeneratingUtil.createRootWithCollectionAttribute; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; @@ -80,41 +85,52 @@ void shouldRemoveFirstPathElement() { assertThat(result).isEqualTo("second"); } + @EnumSource(JoinType.class) + @NullSource + @ParameterizedTest + void shouldCalculateFullPath(JoinType joinType) { + // given + String firstPath = "restriction"; + String secondPath = "attribute"; + Path expectedResult = mock(Path.class); + Root first = createRootWithCollectionAttribute(firstPath, secondPath, expectedResult, joinType); + + // when + Path result = PathResolvingUtil.calculateFullPath(first, joinType, new String[] { firstPath, secondPath }); + + // then + assertThat(result).isEqualTo(expectedResult); + } + @Test - void shouldCalculateFullRestrictionPath() { + void shouldReuseExistingJoinWhenCalculatingPath() { // given String firstPath = "restriction"; String secondPath = "attribute"; - From first = mock(From.class); - Join second = mock(Join.class); - Path third = mock(Path.class); + Path expectedResult = mock(Path.class); - doReturn(second).when(first).join(firstPath); - doReturn(third).when(second).get(secondPath); + Root first = createRootWithAssociationAttribute(firstPath, secondPath, expectedResult); // when - Path result = PathResolvingUtil.calculateFullRestrictionPath(first, new String[] { firstPath, secondPath }); + Path result = PathResolvingUtil.calculateFullPath(first, null, new String[] { firstPath, secondPath }); // then - assertThat(result).isEqualTo(third); + assertThat(result).isEqualTo(expectedResult); } @Test - void shouldCalculateFullSelectionPath() { + void shouldNotJoinClassAttribute() { // given - String firstPath = "selection"; - String secondPath = "attribute"; - Path first = mock(From.class); + String firstPath = "class"; + Root first = mock(Root.class); Path second = mock(Path.class); - Path third = mock(Path.class); doReturn(second).when(first).get(firstPath); - doReturn(third).when(second).get(secondPath); // when - Path result = PathResolvingUtil.calculateFullSelectionPath(first, new String[] { firstPath, secondPath }); + Path result = PathResolvingUtil.calculateFullPath(first, null, new String[] { firstPath }); // then - assertThat(result).isEqualTo(third); + assertThat(result).isEqualTo(second); } } diff --git a/nrich-search/src/test/java/net/croz/nrich/search/util/testutil/PathResolvingUtilGeneratingUtil.java b/nrich-search/src/test/java/net/croz/nrich/search/util/testutil/PathResolvingUtilGeneratingUtil.java new file mode 100644 index 000000000..b1b5b904c --- /dev/null +++ b/nrich-search/src/test/java/net/croz/nrich/search/util/testutil/PathResolvingUtilGeneratingUtil.java @@ -0,0 +1,72 @@ +/* + * Copyright 2020-2023 CROZ d.o.o, 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 net.croz.nrich.search.util.testutil; + +import jakarta.persistence.criteria.Join; +import jakarta.persistence.criteria.JoinType; +import jakarta.persistence.criteria.Path; +import jakarta.persistence.criteria.Root; +import jakarta.persistence.metamodel.Attribute; +import jakarta.persistence.metamodel.EntityType; +import java.util.Set; + +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; + +public final class PathResolvingUtilGeneratingUtil { + + private PathResolvingUtilGeneratingUtil() { + } + + public static Root createRootWithCollectionAttribute(String firstPath, String secondPath, Path result, JoinType joinType) { + Attribute attribute = mock(Attribute.class); + Root root = createRootWithAttribute(firstPath, attribute); + + doReturn(true).when(attribute).isCollection(); + + Join second = mock(Join.class); + doReturn(second).when(root).join(firstPath, joinType == null ? JoinType.INNER : joinType); + doReturn(result).when(second).get(secondPath); + + return root; + } + + public static Root createRootWithAssociationAttribute(String firstPath, String secondPath, Path result) { + Attribute attribute = mock(Attribute.class); + Root root = createRootWithAttribute(firstPath, attribute); + Join second = mock(Join.class); + + doReturn(true).when(attribute).isAssociation(); + doReturn(Set.of(second)).when(root).getJoins(); + doReturn(attribute).when(second).getAttribute(); + doReturn(firstPath).when(attribute).getName(); + doReturn(result).when(second).get(secondPath); + + return root; + } + + private static Root createRootWithAttribute(String attributeName, Attribute attribute) { + EntityType entityType = mock(EntityType.class); + Root root = mock(Root.class); + + doReturn(attribute).when(entityType).getAttribute(attributeName); + doReturn(entityType).when(root).getModel(); + + return root; + } +}