diff --git a/pom.xml b/pom.xml index 2d55c97d..6528dace 100644 --- a/pom.xml +++ b/pom.xml @@ -34,10 +34,13 @@ 30.0-jre + 1.10.15 2.8.0 1.9 2.0.14 2.0.12 + 1.2.18 + 5.4.22.Final 6.1.6.Final 4.5.13 2.0.0 @@ -54,6 +57,7 @@ 42.2.12 5.3.0 4.2.1 + 1.8.3 + + net.bytebuddy + byte-buddy + ${byte-buddy.version} + provided + + commons-io commons-io @@ -236,6 +247,13 @@ + + com.sun.xml.fastinfoset + FastInfoset + ${FastInfoset.version} + provided + + com.github.rholder guava-retrying @@ -253,6 +271,31 @@ + + org.hibernate + hibernate-core + ${hibernate.version} + provided + + + net.bytebuddy + byte-buddy + + + com.sun.xml.fastinfoset + FastInfoset + + + org.javassist + javassist + + + org.jvnet.staxex + stax-ex + + + + org.hibernate.validator hibernate-validator @@ -440,6 +483,13 @@ provided + + org.jvnet.staxex + stax-ex + ${stax-ex.version} + provided + + com.fasterxml.woodstox woodstox-core diff --git a/src/main/java/org/kiwiproject/hibernate/CriteriaQueries.java b/src/main/java/org/kiwiproject/hibernate/CriteriaQueries.java new file mode 100644 index 00000000..27b1399b --- /dev/null +++ b/src/main/java/org/kiwiproject/hibernate/CriteriaQueries.java @@ -0,0 +1,166 @@ +package org.kiwiproject.hibernate; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.collect.Lists.newArrayList; +import static org.apache.commons.lang3.StringUtils.isBlank; +import static org.kiwiproject.base.KiwiStrings.splitWithTrimAndOmitEmpty; +import static org.kiwiproject.collect.KiwiLists.first; +import static org.kiwiproject.collect.KiwiLists.second; + +import com.google.common.annotations.VisibleForTesting; +import lombok.experimental.UtilityClass; +import org.hibernate.Criteria; +import org.hibernate.FetchMode; +import org.hibernate.Session; +import org.hibernate.criterion.CriteriaSpecification; +import org.hibernate.criterion.Order; + +import java.util.List; + +/** + * Utility class for creating Hibernate {@link Criteria} queries. + * + * @implNote Suppressing all IntelliJ and Sonar deprecation warnings. We are aware that the Hibernate Criteria API + * is deprecated. + */ +@UtilityClass +@SuppressWarnings({"java:S1874", "deprecation"}) +public class CriteriaQueries { + + private static final String ASC = "asc"; + private static final String DESC = "desc"; + private static final int ORDER_SPEC_SIZE_WITH_ORDER = 2; + private static final int ORDER_SPEC_SIZE_WITHOUT_ORDER = 1; + private static final String INVALID_ORDER_SPEC_MESSAGE_TEMPLATE = + "'%s' is not a valid order specification. Must contain a property" + + " name optionally followed by (case-insensitive) asc or desc"; + + /** + * Creates a {@link Criteria} query for the specified persistent class, with the specified ordering, + * and setting {@link FetchMode#JOIN} on the specified {@code fetchAssociations}. + * + * @param session the Hibernate session + * @param persistentClass the class for which to create a criteria query + * @param orderClause the order specification + * @param fetchAssociations one or more associations to fetch via a join + * @return a {@code Criteria} which you can build upon + * @see #addOrder(Criteria, String) + * @see #distinctCriteriaWithFetchAssociations(org.hibernate.Session, Class, String...) + */ + public static Criteria distinctCriteria(Session session, + Class persistentClass, + String orderClause, + String... fetchAssociations) { + + var criteria = distinctCriteriaWithOrder(session, persistentClass, orderClause); + addFetchAssociations(criteria, fetchAssociations); + return criteria; + } + + /** + * Creates a {@link Criteria} query for the specified persistent class and the specified HQL order clause. + * + * @param session the Hibernate session + * @param persistentClass the class for which to create a criteria query + * @param orderClause the order specification + * @return a {code Criteria} which you can build upon + * @see #addOrder(Criteria, String) + */ + public static Criteria distinctCriteriaWithOrder(Session session, Class persistentClass, String orderClause) { + var criteria = distinctCriteria(session, persistentClass); + return addOrder(criteria, orderClause); + } + + /** + * Creates a {@link Criteria} query for the specified persistent class and setting + * {@link FetchMode#JOIN} on the specified {@code fetchAssociations}. + * + * @param session the Hibernate session + * @param persistentClass the class for which to create a criteria query + * @param fetchAssociations one or more associations to fetch via a join + * @return a {@code Criteria} which you can build upon + */ + public static Criteria distinctCriteriaWithFetchAssociations(Session session, + Class persistentClass, + String... fetchAssociations) { + + var criteria = distinctCriteria(session, persistentClass); + addFetchAssociations(criteria, fetchAssociations); + return criteria; + } + + /** + * Creates a {@link Criteria} query for the specified persistent class. + * + * @param session the Hibernate session + * @param persistentClass the class for which to create a criteria query + * @return a {@code Criteria} which you can build upon + */ + public static Criteria distinctCriteria(Session session, Class persistentClass) { + return session.createCriteria(persistentClass) + .setResultTransformer(CriteriaSpecification.DISTINCT_ROOT_ENTITY); + } + + private static void addFetchAssociations(Criteria criteria, String... fetchAssociations) { + for (var association : fetchAssociations) { + criteria.setFetchMode(association, FetchMode.JOIN); + } + } + + /** + * Adds the specified order clause to an existing {@link Criteria}, returning the criteria that was passed in. + *

+ * The {@code orderClause} should contain a comma-separated list of properties optionally followed by an order + * designator, which must be {@code asc} or {@code desc}. If neither ascending nor descending is specified, + * ascending order is used. For example, the order clause for listing people by descending date of birth, then + * ascending last name, and finally ascending first name is: + *

+     * dateOfBirth desc, lastName, firstName
+     * 
+ * + * @param criteria an existing {@code Criteria} to add ordering to + * @param orderClause the order specification + * @return the same {@code criteria} instance passed in, to allow building upon it + */ + public static Criteria addOrder(Criteria criteria, String orderClause) { + if (isBlank(orderClause)) { + return criteria; + } + + splitToList(orderClause, ',') + .stream() + .map(CriteriaQueries::toOrderFromPropertyOrderClause) + .forEach(criteria::addOrder); + + return criteria; + } + + @VisibleForTesting + static Order toOrderFromPropertyOrderClause(String propertyOrdering) { + var orderSpec = splitToList(propertyOrdering, ' '); + validateOrderSpecification(orderSpec, propertyOrdering); + + var propertyName = first(orderSpec); + var isAscending = orderSpec.size() < ORDER_SPEC_SIZE_WITH_ORDER || ASC.equalsIgnoreCase(second(orderSpec)); + + return isAscending ? Order.asc(propertyName) : Order.desc(propertyName); + } + + private static void validateOrderSpecification(List orderSpec, String rawPropertyOrder) { + checkArgument( + orderSpec.size() == ORDER_SPEC_SIZE_WITHOUT_ORDER || orderSpec.size() == ORDER_SPEC_SIZE_WITH_ORDER, + INVALID_ORDER_SPEC_MESSAGE_TEMPLATE, rawPropertyOrder); + + if (orderSpec.size() == ORDER_SPEC_SIZE_WITH_ORDER) { + var order = second(orderSpec); + checkArgument(ASC.equalsIgnoreCase(order) || DESC.equalsIgnoreCase(order), + "'%s' is not a valid order. Property order must be either %s or %s (case-insensitive)", + order, ASC, DESC); + } + } + + private static List splitToList(String value, char separator) { + return newArrayList(splitWithTrimAndOmitEmpty(value, separator)); + } + +} diff --git a/src/test/java/org/kiwiproject/hibernate/CriteriaQueriesTest.java b/src/test/java/org/kiwiproject/hibernate/CriteriaQueriesTest.java new file mode 100644 index 00000000..5e292e46 --- /dev/null +++ b/src/test/java/org/kiwiproject/hibernate/CriteriaQueriesTest.java @@ -0,0 +1,280 @@ +package org.kiwiproject.hibernate; + +import static com.google.common.collect.Lists.newArrayList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.kiwiproject.collect.KiwiLists.first; +import static org.kiwiproject.collect.KiwiLists.nth; +import static org.kiwiproject.collect.KiwiLists.second; +import static org.kiwiproject.collect.KiwiLists.third; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import org.hibernate.Criteria; +import org.hibernate.FetchMode; +import org.hibernate.Session; +import org.hibernate.criterion.CriteriaSpecification; +import org.hibernate.engine.spi.SessionImplementor; +import org.hibernate.internal.CriteriaImpl; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; + +import java.util.List; + +@DisplayName("CriteriaQueries") +@SuppressWarnings({"java:S1874", "deprecation"}) +class CriteriaQueriesTest { + + private Session session; + private SessionImplementor sessionImplementor; + + @BeforeEach + void setUp() { + session = mock(Session.class); + sessionImplementor = mock(SessionImplementor.class); + when(session.createCriteria(TheEntity.class)) + .thenReturn(new CriteriaImpl(TheEntity.class.getName(), sessionImplementor)); + } + + @AfterEach + void tearDown() { + verifyNoInteractions(sessionImplementor); + } + + @Nested + class DistinctCriteriaForClass { + + @Test + void shouldCreateDistinctCriteria() { + var criteria = toCriteriaImpl(CriteriaQueries.distinctCriteria(session, TheEntity.class)); + + assertHasDistinctRootEntity(criteria); + } + } + + @Nested + class DistinctCriteriaWithOrder { + + @Test + void shouldCreateCriteriaWithOrder() { + var orderClause = " foo desc, bar, baz "; + var criteria = toCriteriaImpl( + CriteriaQueries.distinctCriteriaWithOrder(session, TheEntity.class, orderClause)); + + assertHasDistinctRootEntity(criteria); + + List orderEntries = newArrayList(criteria.iterateOrderings()); + assertThat(orderEntries).hasSize(3); + + assertOrderEntry(first(orderEntries), "foo", false); + assertOrderEntry(second(orderEntries), "bar", true); + assertOrderEntry(third(orderEntries), "baz", true); + } + } + + @Nested + class DistinctCriteriaWithFetchAssociations { + @Test + void testDistinctCriteria_WithPersistentClass_AndFetchAssociations() { + String[] fetchAssociations = {"foos", "bars"}; + var criteria = toCriteriaImpl( + CriteriaQueries.distinctCriteriaWithFetchAssociations(session, TheEntity.class, fetchAssociations)); + + assertHasDistinctRootEntity(criteria); + + assertThat(criteria.getFetchMode("this.foos")).isEqualTo(FetchMode.JOIN); + assertThat(criteria.getFetchMode("this.bars")).isEqualTo(FetchMode.JOIN); + assertThat(criteria.getFetchMode("this.bazes")).isNull(); + } + } + + @Nested + class DistinctCriteriaWithOrderAndFetchAssociations { + + @Test + void shouldCreateDistinctCriteriaWithOrderAndOneFetchAssociation() { + var orderClause = " foo, bar desc "; + String[] fetchAssociations = {"foos"}; + var criteria = toCriteriaImpl( + CriteriaQueries.distinctCriteria(session, TheEntity.class, orderClause, fetchAssociations)); + + assertHasDistinctRootEntity(criteria); + + List orderEntries = newArrayList(criteria.iterateOrderings()); + assertThat(orderEntries).hasSize(2); + assertOrderEntry(first(orderEntries), "foo", true); + assertOrderEntry(second(orderEntries), "bar", false); + assertThat(criteria.getFetchMode("this.foos")).isEqualTo(FetchMode.JOIN); + } + + @Test + void testDistinctCriteriaWithOrderAndMultipleFetchAssociations() { + var orderClause = " foo, bar "; + String[] fetchAssociations = {"foos", "bars"}; + CriteriaImpl criteria = toCriteriaImpl( + CriteriaQueries.distinctCriteria(session, TheEntity.class, orderClause, fetchAssociations)); + + assertHasDistinctRootEntity(criteria); + + List orderEntries = newArrayList(criteria.iterateOrderings()); + assertThat(orderEntries).hasSize(2); + + assertOrderEntry(first(orderEntries), "foo", true); + assertOrderEntry(second(orderEntries), "bar", true); + + assertThat(criteria.getFetchMode("this.foos")).isEqualTo(FetchMode.JOIN); + assertThat(criteria.getFetchMode("this.bars")).isEqualTo(FetchMode.JOIN); + } + } + + @Nested + class AddOrder { + + @Test + void shouldAddOrderEntries() { + var criteria1 = toCriteriaImpl(session.createCriteria(TheEntity.class)); + + var orderClause = " foo , bar desc, baz ASC, corge "; + var criteria2 = CriteriaQueries.addOrder(criteria1, orderClause); + assertThat(criteria2).isSameAs(criteria1); + + List orderEntries = newArrayList(criteria1.iterateOrderings()); + assertThat(orderEntries).hasSize(4); + + assertOrderEntry(first(orderEntries), "foo", true); + assertOrderEntry(second(orderEntries), "bar", false); + assertOrderEntry(third(orderEntries), "baz", true); + assertOrderEntry(nth(orderEntries, 4), "corge", true); + } + + @ParameterizedTest + @NullAndEmptySource + void shouldIgnore_EmptyOrderClause(String orderClause) { + var criteria1 = toCriteriaImpl(session.createCriteria(TheEntity.class)); + + var criteria2 = CriteriaQueries.addOrder(criteria1, orderClause); + assertThat(criteria2).isSameAs(criteria1); + + List orderEntries = newArrayList(criteria1.iterateOrderings()); + assertThat(orderEntries).isEmpty(); + } + + @Nested + class ShouldThrowIllegalArgumentException { + + @Test + void whenBadOrderInOrderClause() { + var criteria = toCriteriaImpl(session.createCriteria(TheEntity.class)); + + assertThatThrownBy(() -> CriteriaQueries.addOrder(criteria, "foo, bar desc, baz corge")) + .isExactlyInstanceOf(IllegalArgumentException.class) + .hasMessageEndingWith("Property order must be either asc or desc (case-insensitive)"); + } + + @Test + void whenTooManyTokensInOrderClause() { + CriteriaImpl criteria = toCriteriaImpl(session.createCriteria(TheEntity.class)); + assertThatThrownBy(() -> CriteriaQueries.addOrder(criteria, "foo, bar desc, baz asc klunk")) + .isExactlyInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("'baz asc klunk' is not a valid order specification"); + } + } + + /** + * This is testing an internal (package-private) method. + */ + @Nested + class ToOrderFromPropertyOrderClause { + + @ParameterizedTest + @CsvSource({ + "name, name", + "age, age", + "name asc, name", + "name ASC, name", + "age ASC, age", + "age asc, age", + }) + void shouldParseValidAscendingOrderClauses(String propertyOrdering, String expectedPropertyName) { + var order = CriteriaQueries.toOrderFromPropertyOrderClause(propertyOrdering); + + assertThat(order.getPropertyName()).isEqualTo(expectedPropertyName); + assertThat(order.isAscending()).isTrue(); + } + + @ParameterizedTest + @CsvSource({ + "name desc, name", + "name DESC, name", + "age desc, age", + "age DESC, age", + }) + void shouldParseValidDescendingOrderClauses(String propertyOrdering, String expectedPropertyName) { + var order = CriteriaQueries.toOrderFromPropertyOrderClause(propertyOrdering); + + assertThat(order.getPropertyName()).isEqualTo(expectedPropertyName); + assertThat(order.isAscending()).isFalse(); + } + + @ParameterizedTest + @CsvSource({ + "name asc age desc", + "name asc desc", + "name asc bar", + "name foo bar", + }) + void shouldThrowIllegalArgumentException_GivenPropertyOrdering_WithTooManySegments(String propertyOrdering) { + assertThatIllegalArgumentException() + .isThrownBy(() -> CriteriaQueries.toOrderFromPropertyOrderClause(propertyOrdering)) + .withMessage("'%s' is not a valid order specification. Must contain a property name optionally followed by (case-insensitive) asc or desc", propertyOrdering); + } + + @ParameterizedTest + @CsvSource({ + "name a", + "name as", + "name ascending", + "name d", + "name des", + "name descending", + "name foo", + "name bar", + }) + void shouldThrowIllegalArgumentException_GivenInvalidOrderDirection(String propertyOrdering) { + var orderDirection = propertyOrdering.split(" ")[1]; + + assertThatIllegalArgumentException() + .isThrownBy(() -> CriteriaQueries.toOrderFromPropertyOrderClause(propertyOrdering)) + .withMessage("'%s' is not a valid order. Property order must be either asc or desc (case-insensitive)", orderDirection); + } + } + } + + private static void assertOrderEntry(CriteriaImpl.OrderEntry orderEntry, String propertyName, boolean ascending) { + assertThat(orderEntry.getOrder().getPropertyName()).isEqualTo(propertyName); + assertThat(orderEntry.getOrder().isAscending()).isEqualTo(ascending); + } + + private static void assertHasDistinctRootEntity(CriteriaImpl criteria) { + assertThat(criteria.getResultTransformer()).isEqualTo(CriteriaSpecification.DISTINCT_ROOT_ENTITY); + } + + private static class TheEntity { + } + + private static CriteriaImpl toCriteriaImpl(Criteria criteria) { + assertThat(criteria) + .describedAs("Expecting %s to be exactly instance of CriteriaImpl", criteria.getClass()) + .isExactlyInstanceOf(CriteriaImpl.class); + + return (CriteriaImpl) criteria; + } +}