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;
+ }
+}