diff --git a/src/main/java/org/kiwiproject/collect/KiwiCollections.java b/src/main/java/org/kiwiproject/collect/KiwiCollections.java index 48701959..22d94b85 100644 --- a/src/main/java/org/kiwiproject/collect/KiwiCollections.java +++ b/src/main/java/org/kiwiproject/collect/KiwiCollections.java @@ -1,10 +1,18 @@ package org.kiwiproject.collect; +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Preconditions.checkState; import static java.util.Objects.nonNull; import lombok.experimental.UtilityClass; import java.util.Collection; +import java.util.Deque; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.SortedSet; /** * Utility methods for working with {@link Collection} instances. @@ -44,4 +52,158 @@ public static boolean isNotNullOrEmpty(Collection collection) { public static boolean hasOneElement(Collection collection) { return nonNull(collection) && collection.size() == 1; } + + /** + * Returns an {@link Optional} containing the first element in the given sequenced collection, or an empty optional + * if the collection is null or empty. + * + * @param sequencedCollection the sequenced collection + * @param the type of elements in the collection + * @return {@link Optional} containing first element if exists, otherwise Optional.empty() + * @throws IllegalArgumentException if sequencedCollection is not a sequenced collection + */ + public static Optional firstIfPresent(Collection sequencedCollection) { + return isNotNullOrEmpty(sequencedCollection) ? Optional.of(first(sequencedCollection)) : Optional.empty(); + } + + /** + * Return the first element in the given sequenced collection. + * + * @param sequencedCollection the sequenced collection + * @param the type of elements in the collection + * @return the first element of the collection + * @throws IllegalArgumentException if sequencedCollection is null, empty, or not a sequenced collection + * @see #isSequenced(Collection) + */ + public static T first(Collection sequencedCollection) { + checkNotEmptyCollection(sequencedCollection); + checkIsSequenced(sequencedCollection); + + if (sequencedCollection instanceof SortedSet) { + return first((SortedSet) sequencedCollection); + } else if (sequencedCollection instanceof LinkedHashSet) { + return first((LinkedHashSet) sequencedCollection); + } else if (sequencedCollection instanceof List) { + return KiwiLists.first((List) sequencedCollection); + } + + checkState(sequencedCollection instanceof Deque, "expected Deque but was %s", sequencedCollection.getClass()); + + return first((Deque) sequencedCollection); + } + + private static T first(SortedSet sortedSet) { + return sortedSet.first(); + } + + private static T first(LinkedHashSet linkedHashSet) { + return linkedHashSet.iterator().next(); + } + + private static T first(Deque deque) { + return deque.peekFirst(); + } + + /** + * Returns an {@link Optional} containing the last element in the given sequenced collection, or an empty optional + * if the collection is null or empty. + * + * @param sequencedCollection the sequenced collection + * @param the type of elements in the collection + * @return {@link Optional} containing last element if exists, otherwise Optional.empty() + * @throws IllegalArgumentException if sequencedCollection is not a sequenced collection + */ + public static Optional lastIfPresent(Collection sequencedCollection) { + return isNotNullOrEmpty(sequencedCollection) ? Optional.of(last(sequencedCollection)) : Optional.empty(); + } + + /** + * Return the last element in the given sequenced collection. + * + * @param sequencedCollection the sequenced collection + * @param the type of elements in the collection + * @return the last element of the collection + * @throws IllegalArgumentException if sequencedCollection is null, empty, or not a sequenced collection + * @implNote If {@code sequencedCollection} is a {@link LinkedHashSet}, there is no direct way to obtain the + * last element. This implementation creates a {@link java.util.stream.Stream Stream} over the elements, skipping + * until the last element. + * @see #isSequenced(Collection) + */ + public static T last(Collection sequencedCollection) { + checkNotEmptyCollection(sequencedCollection); + checkIsSequenced(sequencedCollection); + + if (sequencedCollection instanceof SortedSet) { + return last((SortedSet) sequencedCollection); + } else if (sequencedCollection instanceof LinkedHashSet) { + return last((LinkedHashSet) sequencedCollection); + } else if (sequencedCollection instanceof List) { + return KiwiLists.last((List) sequencedCollection); + } + + checkState(sequencedCollection instanceof Deque, "expected Deque but was %s", sequencedCollection.getClass()); + + return last((Deque) sequencedCollection); + } + + private static T last(SortedSet sortedSet) { + return sortedSet.last(); + } + + private static T last(LinkedHashSet linkedHashSet) { + var size = linkedHashSet.size(); + return linkedHashSet.stream().skip(size - 1L).findFirst().orElse(null); + } + + private static T last(Deque deque) { + return deque.peekLast(); + } + + /** + * Checks that the given collection is not empty. + * + * @param collection the collection + * @param the type of elements in the collection + * @throws IllegalArgumentException if the given collection is null or empty + */ + public static void checkNotEmptyCollection(Collection collection) { + checkArgument(isNotNullOrEmpty(collection), "collection must contain at least one element"); + } + + /** + * Checks that the given collection is not null. + * + * @param collection the collection + * @param the type of elements in the collection + * @throws NullPointerException if the collection is null + */ + public static void checkNonNullCollection(Collection collection) { + checkNotNull(collection, "collection must not be null"); + } + + private static void checkIsSequenced(Collection collection) { + checkArgument(isSequenced(collection), + "collection of type %s is not supported as a 'sequenced' collection", + Optional.ofNullable(collection).map(coll -> coll.getClass().getName()).orElse("null collection")); + } + + /** + * Checks whether the given collection is "sequenced". + *

+ * The definition of "sequenced" is based on JEP 431: Sequenced Collections + * (as it existed on 2022-11-09). + *

+ * The current {@link Collection} types (and their subtypes and implementations) that are considered sequenced + * include {@link SortedSet}, {@link LinkedHashSet}, {@link List}, and {@link Deque}. + * + * @param collection the collection + * @param the type of elements in the collection + * @return true if the given collection is "sequenced" + */ + public static boolean isSequenced(Collection collection) { + return collection instanceof SortedSet || + collection instanceof LinkedHashSet || + collection instanceof List || + collection instanceof Deque; + } } diff --git a/src/test/java/org/kiwiproject/collect/KiwiCollectionsTest.java b/src/test/java/org/kiwiproject/collect/KiwiCollectionsTest.java index 55fcc169..4fcaab61 100644 --- a/src/test/java/org/kiwiproject/collect/KiwiCollectionsTest.java +++ b/src/test/java/org/kiwiproject/collect/KiwiCollectionsTest.java @@ -1,17 +1,32 @@ package org.kiwiproject.collect; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; +import com.google.common.collect.HashMultiset; +import com.google.common.collect.ImmutableSet; 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.MethodSource; import org.junit.jupiter.params.provider.NullAndEmptySource; import java.time.Instant; import java.time.LocalDateTime; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; import java.util.Set; +import java.util.TreeSet; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.CopyOnWriteArraySet; +import java.util.stream.Stream; @DisplayName("KiwiCollections") class KiwiCollectionsTest { @@ -103,4 +118,217 @@ void shouldBeFalse_WhenSetHasMoreThanOneElement() { assertThat(KiwiCollections.hasOneElement(Set.of(2, 4))).isFalse(); } } + + @Nested + class FirstIfPresent { + + @Test + void shouldThrowIllegalArgumentException_WhenCollectionIsNotSequenced() { + var set = Set.of(1, 2, 3); + assertThatIllegalArgumentException() + .isThrownBy(() -> KiwiCollections.firstIfPresent(set)) + .withMessage("collection of type %s is not supported as a 'sequenced' collection", set.getClass().getName()); + } + + @ParameterizedTest + @NullAndEmptySource + void shouldReturnEmptyOptional_WhenCollectionIsNullOrEmpty(List list) { + assertThat(KiwiCollections.firstIfPresent(list)).isEmpty(); + } + + @Test + void shouldReturnOptionalContainingFirstElement_WhenCollectionIsNotEmpty() { + assertThat(KiwiCollections.firstIfPresent(List.of(1, 2, 3, 4, 5))).contains(1); + } + } + + @Nested + class First { + + @Test + void shouldThrowIllegalArgumentException_WhenCollectionIsNotSequenced() { + var set = Set.of(4, 5); + assertThatIllegalArgumentException() + .isThrownBy(() -> KiwiCollections.first(set)) + .withMessage("collection of type %s is not supported as a 'sequenced' collection", set.getClass().getName()); + } + + @ParameterizedTest + @NullAndEmptySource + void shouldThrowIllegalArgumentException_WhenCollectionIsNullOrEmpty(List list) { + assertThatIllegalArgumentException() + .isThrownBy(() -> KiwiCollections.first(list)) + .withMessage("collection must contain at least one element"); + } + + @Test + void shouldReturnFirstElementOfSortedSet() { + var sortedSet = new TreeSet<>(List.of(5, 3, 4, 1, 2)); + assertThat(KiwiCollections.first(sortedSet)).isEqualTo(1); + } + + @Test + void shouldReturnFirstElementOfLinkedHashSet() { + var values = List.of(4, 3, 1, 2, 5); + var linkedHashSet = new LinkedHashSet<>(values); + assertThat(KiwiCollections.first(linkedHashSet)).isEqualTo(4); + } + + @Test + void shouldReturnFirstElementOfList() { + assertThat(KiwiCollections.first(List.of(3, 5, 4, 1, 2))).isEqualTo(3); + } + + @Test + void shouldReturnFirstElementOfDeque() { + var values = List.of(42, 56, 31, 78, 99); + var deque = new ArrayDeque<>(values); + assertThat(KiwiCollections.first(deque)).isEqualTo(42); + assertThat(deque) + .describedAs("no element should have been removed") + .hasSameSizeAs(values); + } + } + + @Nested + class LastIfPresent { + + @Test + void shouldThrowIllegalArgumentException_WhenCollectionIsNotSequenced() { + var set = Set.of(42, 84, 24); + assertThatIllegalArgumentException() + .isThrownBy(() -> KiwiCollections.lastIfPresent(set)) + .withMessage("collection of type %s is not supported as a 'sequenced' collection", set.getClass().getName()); + } + + @ParameterizedTest + @NullAndEmptySource + void shouldReturnEmptyOptional_WhenCollectionIsNullOrEmpty(List list) { + assertThat(KiwiCollections.lastIfPresent(list)).isEmpty(); + } + + @Test + void shouldReturnOptionalContainingLastElement_WhenCollectionIsNotEmpty() { + assertThat(KiwiCollections.lastIfPresent(List.of(1, 2, 3, 4, 5))).contains(5); + } + } + + @Nested + class Last { + + @Test + void shouldThrowIllegalArgumentException_WhenCollectionIsNotSequenced() { + var set = Set.of(12, 24, 36, 48); + assertThatIllegalArgumentException() + .isThrownBy(() -> KiwiCollections.last(set)) + .withMessage("collection of type %s is not supported as a 'sequenced' collection", set.getClass().getName()); + } + + @ParameterizedTest + @NullAndEmptySource + void shouldThrowIllegalArgumentException_WhenCollectionIsNullOrEmpty(List list) { + assertThatIllegalArgumentException() + .isThrownBy(() -> KiwiCollections.last(list)) + .withMessage("collection must contain at least one element"); + } + + @Test + void shouldReturnLastElementOfSortedSet() { + var sortedSet = new TreeSet<>(List.of(5, 3, 4, 1, 2)); + assertThat(KiwiCollections.last(sortedSet)).isEqualTo(5); + } + + @Test + void shouldReturnLastElementOfLinkedHashSet() { + var values = List.of(4, 3, 1, 2, 5); + var linkedHashSet = new LinkedHashSet<>(values); + assertThat(KiwiCollections.last(linkedHashSet)).isEqualTo(5); + } + + @Test + void shouldReturnLastElementOfList() { + assertThat(KiwiCollections.last(List.of(3, 5, 4, 1, 2))).isEqualTo(2); + } + + @Test + void shouldReturnLastElementOfDeque() { + var values = List.of(42, 56, 31, 78, 99); + var deque = new ArrayDeque<>(values); + assertThat(KiwiCollections.last(deque)).isEqualTo(99); + assertThat(deque) + .describedAs("no element should have been removed") + .hasSameSizeAs(values); + } + } + + @Nested + class CheckNotEmptyCollection { + + @ParameterizedTest + @NullAndEmptySource + void shouldThrowIllegalArgumentException_WhenGivenNullOrEmptyCollection(Set collection) { + assertThatIllegalArgumentException() + .isThrownBy(() -> KiwiCollections.checkNotEmptyCollection(collection)) + .withMessage("collection must contain at least one element"); + } + + @Test + void shouldNotThrow_WhenGivenNotEmptyCollection() { + assertThatCode(() -> KiwiCollections.checkNotEmptyCollection(Set.of(42))) + .doesNotThrowAnyException(); + } + } + + @Nested + class CheckNonNullCollection { + + @SuppressWarnings("ConstantConditions") + @Test + void shouldThrowIllegalArgumentException_WhenGivenNullCollection() { + assertThatNullPointerException() + .isThrownBy(() -> KiwiCollections.checkNonNullCollection(null)) + .withMessage("collection must not be null"); + } + + @Test + void shouldNotThrow_WhenGivenNotNullCollection() { + assertThatCode(() -> KiwiCollections.checkNonNullCollection(Set.of())) + .doesNotThrowAnyException(); + } + } + + @Nested + class IsSequenced { + + @ParameterizedTest + @MethodSource("org.kiwiproject.collect.KiwiCollectionsTest#supportedSequencedCollections") + void shouldBeTrue_ForSupportedCollectionTypes(Collection collection) { + assertThat(KiwiCollections.isSequenced(collection)).isTrue(); + } + + @ParameterizedTest + @MethodSource("org.kiwiproject.collect.KiwiCollectionsTest#someNonSequencedCollections") + void shouldBeFalse_ForUnsupportedCollectionTypes(Collection collection) { + assertThat(KiwiCollections.isSequenced(collection)).isFalse(); + } + } + + static Stream> supportedSequencedCollections() { + return Stream.of( + new TreeSet<>(), + new LinkedHashSet<>(), + new ArrayList<>(), + new ArrayDeque<>() + ); + } + + static Stream> someNonSequencedCollections() { + return Stream.of( + new HashSet<>(), + new CopyOnWriteArraySet<>(), + ImmutableSet.of(), + new ArrayBlockingQueue<>(5), + HashMultiset.create() + ); + } }