From eeb80988b87f1b19dd9ade8a54919b2bbc536b54 Mon Sep 17 00:00:00 2001 From: Todd Ginsberg Date: Tue, 11 Feb 2025 19:25:10 -0500 Subject: [PATCH] GH-88: Implement Grouping, Filtering, and Valiation of Increasing/Decreasing/NonIncreasing/NonDecreasing (#93) + Group elements to Lists so long as they are increasing, decreasing, non-increasing, or non-decreasing + Ensure that streams are increasing, decreasing, non-increasing, or non-decreasing and fail otherwise + Filter streams to be increasing, decreasing, non-increasing, or non-decreasing + Group, Filter, and Ensure functions can operate on a stream of `Comparable` objects or caller can specify a `Comparator` --- CHANGELOG.md | 3 + README.md | 197 ++++-- .../gatherers4j/ChangingOperation.java | 46 ++ .../gatherers4j/FilterChangingGatherer.java | 85 +++ .../gatherers4j/FlattenSingleOrFail.java | 67 ++ .../com/ginsberg/gatherers4j/Gatherers4j.java | 329 ++++++++- .../gatherers4j/GroupChangingGatherer.java | 91 +++ .../FilterChangingGathererTest.java | 418 ++++++++++++ .../gatherers4j/FlattenSingleOrFailTest.java | 93 +++ .../GroupChangingGathererTest.java | 643 ++++++++++++++++++ 10 files changed, 1906 insertions(+), 66 deletions(-) create mode 100644 src/main/java/com/ginsberg/gatherers4j/ChangingOperation.java create mode 100644 src/main/java/com/ginsberg/gatherers4j/FilterChangingGatherer.java create mode 100644 src/main/java/com/ginsberg/gatherers4j/FlattenSingleOrFail.java create mode 100644 src/main/java/com/ginsberg/gatherers4j/GroupChangingGatherer.java create mode 100644 src/test/java/com/ginsberg/gatherers4j/FilterChangingGathererTest.java create mode 100644 src/test/java/com/ginsberg/gatherers4j/FlattenSingleOrFailTest.java create mode 100644 src/test/java/com/ginsberg/gatherers4j/GroupChangingGathererTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 4521734..eff09f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ ### 0.9.0 + GH-86: Implement `filterInstanceOf` to filter a stream by type more easily (thanks @nipafx) + Implement `windowed` to provide more options to windowing functions, namely - ability to specify size, how many to skip each time, and whether to include partial windows ++ GH-88: Implement `groupIncreasing`, `groupDecreasing`, `groupNonIncreasing`, and `groupNonDecreasing` with both `Comparable` stream inputs or using an explicit `Comparator` to appropriately group elements in the input stream to lists in the output stream (thanks @nipafx) ++ Implement `ensureIncreasing`, `ensureDecreasing`, `ensureNonIncreasing` and `ensureNonDecreasing` with both `Comparable` stream inputs or using an explicit `Comparator` to ensure the given stream meets the criteria, or fail exceptionally otherwise ++ Implement `filterIncreasing`, `filterDecreasing`, `filterNonIncreasing` and `filterNonDecreasing` with both `Comparable` stream inputs or using an explicit `Comparator` to remove non-compliant elements from the input stream ### 0.8.0 + Add support for `orElse()` and `orElseEmpty()` on size-based gatherers to provide a non-exceptional output stream diff --git a/README.md b/README.md index 2524be5..79b9421 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ dependency - the [JSpecify](https://jspecify.dev/) set of annotations for static Add the following dependency to `pom.xml`. ```xml + com.ginsberg gatherers4j @@ -27,50 +28,100 @@ Add the following dependency to `build.gradle` or `build.gradle.kts` implementation("com.ginsberg:gatherers4j:0.9.0") ``` + # Gatherers In This Library -### Streams - -| Function | Purpose | -|--------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------| -| `cross(iterable)` | Emit each element of the source stream with each element of the given `iterable` as a `Pair` to the output stream | -| `cross(iterator)` | Emit each element of the source stream with each element of the given `iterator` as a `Pair` to the output stream | -| `cross(stream)` | Emit each element of the source stream with each element of the given `stream` as a `Pair` to the output stream | -| `debounce(amount, duration)` | Limit stream elements to `amount` elements over `duration`, dropping any elements over the limit until a new `duration` starts | -| `dedupeConsecutive()` | Remove consecutive duplicates from a stream | -| `dedupeConsecutiveBy(fn)` | Remove consecutive duplicates from a stream as returned by `fn` | -| `distinctBy(fn)` | Emit only distinct elements from the stream, as measured by `fn` | -| `dropLast(n)` | Keep all but the last `n` elements of the stream | -| `everyNth(n)` | Limit the stream to every `n`th element | -| `filterInstanceOf(types)` | Filter the stream to only include elements of the given type(s) | -| `filterWithIndex(predicate)` | Filter the stream with the given `predicate`, which takes an `element` and its `index` | -| `foldIndexed(fn)` | Perform a fold over the input stream where each element is included along with its index | -| `grouping()` | Group consecutive identical elements into lists | -| `groupingBy(fn)` | Group consecutive elements that are identical according to `fn` into lists | -| `interleave(iterable)` | Creates a stream of alternating objects from the input stream and the argument iterable | -| `interleave(iterator)` | Creates a stream of alternating objects from the input stream and the argument iterator | -| `interleave(stream)` | Creates a stream of alternating objects from the input stream and the argument stream | -| `last(n)` | Constrain the stream to the last `n` values | -| `orderByFrequencyAscending()` | Returns a stream where elements are ordered from least to most frequent as `WithCount` wrapper objects. | -| `orderByFrequencyDescending()` | Returns a stream where elements are ordered from most to least frequent as `WithCount` wrapper objects. | -| `reverse()` | Reverse the order of the stream | -| `scanIndexed(fn)` | Performs a scan on the input stream using the given function, and includes the index of the elements | -| `shuffle()` | Shuffle the stream into a random order using the platform default `RandomGenerator` | -| `shuffle(rg)` | Shuffle the stream into a random order using the specified `RandomGenerator` | -| `sizeExactly(n)` | Ensure the stream is exactly `n` elements long, or throw an `IllegalStateException` | -| `sizeGreaterThan(n)` | Ensure the stream is greater than `n` elements long, or throw an `IllegalStateException` | -| `sizeGreaterThanOrEqualTo(n)` | Ensure the stream is greater than or equal to `n` elements long, or throw an `IllegalStateException` | -| `sizeLessThan(n)` | Ensure the stream is less than `n` elements long, or throw an `IllegalStateException` | -| `sizeLessThanOrEqualTo(n)` | Ensure the stream is less than or equal to `n` elements long, or throw an `IllegalStateException` | -| `takeUntil(predicate)` | Take elements from the input stream until the `predicate` is met, including the first element that matches the `preciate` | -| `throttle(amount, duration)` | Limit stream elements to `amount` elements over `duration`, pausing until a new `duration` period starts | -| `uniquelyOccurring()` | Emit elements that occur a single time, dropping all others | -| `windowed(size,step,partial) | Create windows over the input stream that are `size` elements long, sliding over `step` elements each time, optionally including `partial` windows | -| `withIndex()` | Maps all elements of the stream as-is along with their 0-based index | -| `zipWith(iterable)` | Creates a stream of `Pair` objects whose values come from the input stream and argument iterable | -| `zipWith(iterator)` | Creates a stream of `Pair` objects whose values come from the input stream and argument iterator | -| `zipWith(stream)` | Creates a stream of `Pair` objects whose values come from the input stream and argument stream | -| `zipWithNext()` | Creates a stream of `List` objects via a sliding window of width 2 and stepping 1 | +For convenience, the full list of gatherers in this library are broken into four categories: + +1. [General Functions](#general-functions) +2. [Filtering Functions](#filtering-functions) +2. [Grouping Functions](#grouping-functions) +3. [Stream Content Checks/Validation](#stream-content-checksvalidation) +4. [Mathematics/Statistics](#mathematicsstatistics) + +## General Functions + +Functions that don't (yet!) fall into one of the other categories. + +| Function | Purpose | +|-----------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------| +| `cross()` | Emit each element of the source stream with each element of the given `iterable`, `iterator`, or `stream` as a `Pair` to the output stream | +| `foldIndexed(fn)` | Perform a fold over the input stream where each element is included along with its index | +| `interleave()` | Creates a stream of alternating objects from the input stream and the argument `iterable`, `iterator`, or `stream` | +| `orderByFrequencyAscending()` | Returns a stream where elements are ordered from least to most frequent as `WithCount` wrapper objects. | +| `orderByFrequencyDescending()` | Returns a stream where elements are ordered from most to least frequent as `WithCount` wrapper objects. | +| `reverse()` | Reverse the order of the stream | +| `scanIndexed(fn)` | Performs a scan on the input stream using the given function, and includes the index of the elements | +| `shuffle()` | Shuffle the stream into a random order using the platform default `RandomGenerator` | +| `shuffle(rg)` | Shuffle the stream into a random order using the specified `RandomGenerator` | +| `throttle(amount, duration)` | Limit stream elements to `amount` elements over `duration`, pausing until a new `duration` period starts | +| `withIndex()` | Maps all elements of the stream as-is along with their 0-based index | +| `zipWith( )` | Creates a stream of `Pair` objects whose values come from the input stream and argument `iterable`, `iterator`, or `stream` | +| `zipWithNext()` | Creates a stream of `List` objects via a sliding window of width 2 and stepping 1 | + +## Filtering Functions + +Functions that remove elements (or retain them, depending on how you look at it) from a stream + +| Function | Purpose | +|-----------------------------------|--------------------------------------------------------------------------------------------------------------------------------| +| `debounce(amount, duration)` | Limit stream elements to `amount` elements over `duration`, dropping any elements over the limit until a new `duration` starts | +| `dedupeConsecutive()` | Remove consecutive duplicates from a stream | +| `dedupeConsecutiveBy(fn)` | Remove consecutive duplicates from a stream as returned by `fn` | +| `distinctBy(fn)` | Emit only distinct elements from the stream, as measured by `fn` | +| `dropLast(n)` | Keep all but the last `n` elements of the stream | +| `everyNth(n)` | Limit the stream to every `n`th element | +| `filterDecreasing()` | Filter the input stream of `Comparable` objects so that it contains only strictly decreasing objects | | +| `filterDecreasing(comparator)` | Filter the input stream of objects so that it contins only strictly decreasing objects, as measured by a given `Comparator` | +| `filterIncreasing()` | Filter the input stream of `Comparable` objects so that it contains only strictly increasing objects | +| `filterIncreasing(comparator)` | Filter the input stream of objects so that it contins only strictly increasing objects, as measured by a given `Comparator` | +| `filterNonDecreasing()` | Filter the input stream of `Comparable` objects so that it contains only non-decreasing objects | +| `filterNonDecreasing(comparator)` | Filter the input stream of objects so that it contins only non-decreasing objects, as measured by a given `Comparator` | +| `filterNonIncreasing()` | Filter the input stream of `Comparable` objects so that it contains non-increasing objects | +| `filterNonIncreasing(comparator)` | Filter the input stream of objects so that it contins only non-increasing objects, as measured by a given `Comparator` | +| `filterInstanceOf(types)` | Filter the stream to only include elements of the given type(s) | +| `filterWithIndex(predicate)` | Filter the stream with the given `predicate`, which takes an `element` and its `index` | +| `last(n)` | Constrain the stream to the last `n` values | +| `takeUntil(predicate)` | Take elements from the input stream until the `predicate` is met, including the first element that matches the `preciate` | +| `uniquelyOccurring()` | Emit elements that occur a single time, dropping all others | + +## Grouping Functions + +Functions that group input elements by varying criteria. + +| Function | Purpose | +|----------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------| +| `grouping()` | Group consecutive identical elements into lists | +| `groupingBy(fn)` | Group consecutive elements that are identical according to `fn` into lists | +| `groupDecreasing()` | Group decreasing `Comparable` elements in the input stream to lists in the output stream | +| `groupDecreasing(comparator)` | Group decreasing elements as measured by a `Comparator` in the input stream to lists in the output stream | +| `groupIncreasing()` | Group increasing `Comparable` elements in the input stream to lists in the output stream | +| `groupIncreasing(comparator)` | Group increasing elements as measured by a `Comparator` in the input stream to lists in the output stream | +| `groupNonIncreasing()` | Group non-increasing `Comparable` elements in the input stream to lists in the output stream | +| `groupNonIncreasing(comparator)` | Group non-increasing elements as measured by a `Comparator` in the input stream to lists in the output stream | +| `groupNonDecreasing()` | Group non-decreasing `Comparable` elements in the input stream to lists in the output stream | +| `groupNonDecreasing(comparator)` | Group non-decreasing elements as measured by a `Comparator` in the input stream to lists in the output stream | +| `windowed(size,step,partial)` | Create windows over the input stream that are `size` elements long, sliding over `step` elements each time, optionally including `partial` windows | + +## Stream Content Checks/Validation + +These gatherers check invariants about streams and fail if they are not met. + +| Function | Purpose | +|-----------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------| +| `ensureDecreasing()` | Ensure that the input stream of `Comparable` objects contains only strictly decreasing objects, and fail otherwise. | | +| `ensureDecreasing(comparator)` | Ensure that the input stream of objects contins only strictly decreasing objects, as measured by a given `Comparator`, and fail otherwise. | +| `ensureIncreasing()` | Ensure that the input stream of `Comparable` objects contains only strictly increasing objects, and fail otherwise. | +| `ensureIncreasing(comparator)` | Ensure that the input stream of objects contins only strictly increasing objects, as measured by a given `Comparator`, and fail otherwise. | +| `ensureNonDecreasing()` | Ensure that the input stream of `Comparable` objects contains only non-decreasing objects, and fail otherwise. | +| `ensureNonDecreasing(comparator)` | Ensure that the input stream of objects contins only non-decreasing objects, as measured by a given `Comparator`, and fail otherwise. | +| `ensureNonIncreasing()` | Ensure that the input stream of `Comparable` objects contains non-increasing objects, and fail otherwise. | +| `ensureNonIncreasing(comparator)` | Ensure that the input stream of objects contins only non-increasing objects, as measured by a given `Comparator`, and fail otherwise. | +| `sizeExactly(n)` | Ensure the stream is exactly `n` elements long, or throw an `IllegalStateException` | +| `sizeGreaterThan(n)` | Ensure the stream is greater than `n` elements long, or throw an `IllegalStateException` | +| `sizeGreaterThanOrEqualTo(n)` | Ensure the stream is greater than or equal to `n` elements long, or throw an `IllegalStateException` | +| `sizeLessThan(n)` | Ensure the stream is less than `n` elements long, or throw an `IllegalStateException` | +| `sizeLessThanOrEqualTo(n)` | Ensure the stream is less than or equal to `n` elements long, or throw an `IllegalStateException` | ### Mathematics/Statistics @@ -164,7 +215,7 @@ Stream // [Person("Todd", "Ginsberg"), Person("Emma", "Ginsberg")] ``` -#### Keep all but the last `n` elements +#### Keep all but the last `n` elements ```java Stream.of("A", "B", "C", "D", "E") @@ -553,6 +604,56 @@ Stream // [["A", "B"], ["B", "C"], ["C", "D"], ["D", "E"]] ``` + +#### Ensure Increasing/Decreasing Order of Stream + +```java +// Success +Stream.of(1,2,3) + .gather(Gatherers4j.ensureIncreasing()) + .toList(); + +// Success +Stream.of(3,2,1) + .gather(Gatherers4j.ensureDecreasing()) + .toList(); + +// Success +Stream.of(1,1,2) + .gather(Gatherers4j.ensureNonDecreasing()) + .toList(); + +// Success +Stream.of(2,2,1) + .gather(Gatherers4j.ensureNonIncreasing()) + .toList(); + +// Fail +Stream.of(3,2,1) + .gather(Gatherers4j.ensureIncreasing()) + .toList(); +// thrown: IllegalStateException + +// Fail +Stream.of(1,2,3) + .gather(Gatherers4j.ensureDecreasing()) + .toList(); +// thrown: IllegalStateException + +// Fail +Stream.of(2,2,1) + .gather(Gatherers4j.ensureNonDecreasing()) + .toList(); +// thrown: IllegalStateException + +// Fail +Stream.of(2,2,3) + .gather(Gatherers4j.ensureNonIncreasing()) + .toList(); +// thrown: IllegalStateException +``` + + ## Streams of `BigDecimal` Functions which modify output and are available on all `BigDecimal` gatherers (simple average, moving average, and standard deviation). @@ -602,14 +703,18 @@ someStreamOfBigDecimal() ] ``` + # Project Philosophy -1. Consider adding a gatherer if it cannot be implemented with `map`, `filter`, or a collector without enclosing outside state. -2. Resist the temptation to add functions that only exist to provide an alias. They seem fun/handy but add surface area to the API and must be maintained forever. +1. Consider adding a gatherer if it cannot be implemented with `map`, `filter`, or a collector without enclosing outside + state. +2. Resist the temptation to add functions that only exist to provide an alias. They seem fun/handy but add surface area + to the API and must be maintained forever. 3. All features should be documented and tested. # Contributing -Please feel free to file issues for change requests or bugs. If you would like to contribute new functionality, please contact me before starting work! +Please feel free to file issues for change requests or bugs. If you would like to contribute new functionality, please +contact me before starting work! Copyright © 2024-2025 by Todd Ginsberg \ No newline at end of file diff --git a/src/main/java/com/ginsberg/gatherers4j/ChangingOperation.java b/src/main/java/com/ginsberg/gatherers4j/ChangingOperation.java new file mode 100644 index 0000000..f965ae9 --- /dev/null +++ b/src/main/java/com/ginsberg/gatherers4j/ChangingOperation.java @@ -0,0 +1,46 @@ +/* + * Copyright 2025 Todd Ginsberg + * + * 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 + * + * http://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 com.ginsberg.gatherers4j; + +enum ChangingOperation { + Decreasing { + @Override + boolean allows(final int comparison) { + return comparison < 0; + } + }, + Increasing { + @Override + boolean allows(final int comparison) { + return comparison > 0; + } + }, + NonDecreasing { + @Override + boolean allows(final int comparison) { + return comparison >= 0; + } + }, + NonIncreasing { + @Override + boolean allows(final int comparison) { + return comparison <= 0; + } + }; + + abstract boolean allows(final int comparison); +} diff --git a/src/main/java/com/ginsberg/gatherers4j/FilterChangingGatherer.java b/src/main/java/com/ginsberg/gatherers4j/FilterChangingGatherer.java new file mode 100644 index 0000000..a42aa3c --- /dev/null +++ b/src/main/java/com/ginsberg/gatherers4j/FilterChangingGatherer.java @@ -0,0 +1,85 @@ +/* + * Copyright 2025 Todd Ginsberg + * + * 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 + * + * http://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 com.ginsberg.gatherers4j; + +import org.jspecify.annotations.Nullable; + +import java.util.Comparator; +import java.util.function.Supplier; +import java.util.stream.Gatherer; + +import static com.ginsberg.gatherers4j.GathererUtils.mustNotBeNull; + +public class FilterChangingGatherer + implements Gatherer, INPUT> { + + private final ChangingOperation operation; + private final Comparator comparator; + + static FilterChangingGatherer usingComparator( + final ChangingOperation operation, + final Comparator comparator + ) { + return new FilterChangingGatherer<>(operation, comparator); + } + + static > FilterChangingGatherer usingComparable( + final ChangingOperation operation + ) { + return new FilterChangingGatherer<>(operation, Comparable::compareTo); + } + + FilterChangingGatherer( + final ChangingOperation operation, + final Comparator comparator + ) { + mustNotBeNull(operation, "Operation must not be null"); + mustNotBeNull(comparator, "Comparator must not be null"); + this.operation = operation; + this.comparator = comparator; + } + + @Override + public Supplier> initializer() { + return State::new; + } + + @Override + public Integrator, INPUT, INPUT> integrator() { + return Integrator.ofGreedy((state, element, downstream) -> { + if (state.first) { + downstream.push(element); + state.previousElement = element; + state.first = false; + } else if (allow(state.previousElement, element)) { + downstream.push(element); + state.previousElement = element; + } + return !downstream.isRejecting(); + }); + } + + boolean allow(final @Nullable INPUT previous, final INPUT next) { + return operation.allows(comparator.compare(next, previous)); + } + + public static class State { + boolean first = true; + @Nullable + INPUT previousElement; + } +} diff --git a/src/main/java/com/ginsberg/gatherers4j/FlattenSingleOrFail.java b/src/main/java/com/ginsberg/gatherers4j/FlattenSingleOrFail.java new file mode 100644 index 0000000..f448750 --- /dev/null +++ b/src/main/java/com/ginsberg/gatherers4j/FlattenSingleOrFail.java @@ -0,0 +1,67 @@ +/* + * Copyright 2025 Todd Ginsberg + * + * 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 + * + * http://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 com.ginsberg.gatherers4j; + +import org.jspecify.annotations.Nullable; + +import java.util.Collection; +import java.util.function.BiConsumer; +import java.util.function.Supplier; +import java.util.stream.Gatherer; + +/// Note: "Single" in this case means at most one. The naming of this more precisely seemed clumsy. +class FlattenSingleOrFail, OUTPUT> + implements Gatherer, OUTPUT> { + + private final String message; + + FlattenSingleOrFail(final String message) { + this.message = message; + } + + @Override + public Supplier> initializer() { + return State::new; + } + + @Override + public Integrator, INPUT, OUTPUT> integrator() { + return (state, element, downstream) -> { + if (state.isFirst) { + state.firstCollection = element; + state.isFirst = false; + return !downstream.isRejecting(); + } else { + throw new IllegalStateException(message); + } + }; + } + + @Override + public BiConsumer, Downstream> finisher() { + return (inputState, downstream) -> { + if(inputState.firstCollection != null) { + inputState.firstCollection.forEach(downstream::push); + } + }; + } + + public static class State { + boolean isFirst = true; + @Nullable INPUT firstCollection; + } +} diff --git a/src/main/java/com/ginsberg/gatherers4j/Gatherers4j.java b/src/main/java/com/ginsberg/gatherers4j/Gatherers4j.java index 362d9e5..5dc4ae2 100644 --- a/src/main/java/com/ginsberg/gatherers4j/Gatherers4j.java +++ b/src/main/java/com/ginsberg/gatherers4j/Gatherers4j.java @@ -20,7 +20,9 @@ import java.math.BigDecimal; import java.time.Duration; +import java.util.Comparator; import java.util.Iterator; +import java.util.List; import java.util.function.BiPredicate; import java.util.function.Function; import java.util.function.Predicate; @@ -133,6 +135,85 @@ public abstract class Gatherers4j { return new DropLastGatherer<>(count); } + /// Ensure that the `Comparable` elements in the input stream are strictly decreasing, and fail otherwise. + /// + /// @param Type of elements in the input stream + /// @return A non-null Gatherer + public static > Gatherer ensureDecreasing() { + final Gatherer> generic = GroupChangingGatherer.usingComparable(ChangingOperation.Decreasing); + return generic.andThen(new FlattenSingleOrFail<>("ensureNonDecreasing detected non-decreasing element")); + } + + /// Ensure that the elements in the input stream are strictly decreasing as measured by the given `Comparator`, and fail otherwise. + /// + /// @param Type of elements in the input stream + /// @param comparator The non-null comparator used to compare stream elements + /// @return A non-null Gatherer + public static Gatherer ensureDecreasing(final Comparator comparator) { + return GroupChangingGatherer.usingComparator(ChangingOperation.Decreasing, comparator) + .andThen(new FlattenSingleOrFail<>("Elements are not strictly decreasing")); + } + + /// Ensure that the `Comparable` elements in the input stream are strictly increasing, and fail otherwise. + /// + /// @param Type of elements in the input stream + /// @return A non-null Gatherer + public static > Gatherer ensureIncreasing() { + final Gatherer> generic = GroupChangingGatherer.usingComparable(ChangingOperation.Increasing); + return generic.andThen(new FlattenSingleOrFail<>("ensureNonDecreasing detected non-increasing element")); + } + + /// Ensure that the elements in the input stream are strictly increasing as measured by the given `Comparator`, and fail otherwise. + /// + /// @param Type of elements in the input stream + /// @param comparator The non-null comparator used to compare stream elements + /// @return A non-null Gatherer + public static Gatherer ensureIncreasing(final Comparator comparator) { + return GroupChangingGatherer.usingComparator(ChangingOperation.Increasing, comparator) + .andThen(new FlattenSingleOrFail<>("Elements are not strictly increasing")); + } + + /// Ensure that the `Comparable` elements in the input stream are not strictly decreasing, and fail otherwise. + /// + /// @param Type of elements in the input stream + /// @throws IllegalStateException If the stream contains elements in a not strictly decreasing order + /// @return A non-null Gatherer + public static > Gatherer ensureNonDecreasing() { + final Gatherer> generic = GroupChangingGatherer.usingComparable(ChangingOperation.NonDecreasing); + return generic.andThen(new FlattenSingleOrFail<>("ensureNonDecreasing detected decrease")); + } + + /// Ensure that the elements in the input stream are not strictly decreasing as measured by the given `Comparator`, and fail otherwise. + /// + /// @param Type of elements in the input stream + /// @param comparator The non-null comparator used to compare stream elements + /// @throws IllegalStateException If the stream contains elements in a not strictly decreasing order + /// @return A non-null Gatherer + public static Gatherer ensureNonDecreasing(final Comparator comparator) { + return GroupChangingGatherer.usingComparator(ChangingOperation.NonDecreasing, comparator) + .andThen(new FlattenSingleOrFail<>("Elements are decreasing")); + } + + /// Ensure that the `Comparable` elements in the input stream are not in a strictly increasing order, and fail otherwise. + /// + /// @param Type of elements in the input stream + /// @return A non-null Gatherer + public static > Gatherer ensureNonIncreasing() { + final Gatherer> generic = GroupChangingGatherer.usingComparable(ChangingOperation.NonIncreasing); + return generic.andThen(new FlattenSingleOrFail<>("ensureNonIncreasing detected increase")); + } + + /// Ensure that the elements in the input stream are not in a strictly increasing order as measured by the + /// given `Comparator`, and fail otherwise. + /// + /// @param Type of elements in the input stream + /// @param comparator The non-null comparator used to compare stream elements + /// @return A non-null Gatherer + public static Gatherer ensureNonIncreasing(final Comparator comparator) { + return GroupChangingGatherer.usingComparator(ChangingOperation.NonIncreasing, comparator) + .andThen(new FlattenSingleOrFail<>("Elements are increasing")); + } + /// Keep every nth element of the stream. /// /// @param count The number of the elements to keep, must be at least 2 @@ -142,6 +223,40 @@ public abstract class Gatherers4j { return new EveryNthGatherer<>(count); } + /// Filter the input stream so that it contains `Comparable` elements in a strictly decreasing order. + /// + /// @param Type of elements in the input and output stream + /// @return A non-null gatherer + public static > Gatherer filterDecreasing() { + return FilterChangingGatherer.usingComparable(ChangingOperation.Decreasing); + } + + /// Filter the input stream so that it contains elements in a strictly decreasing order as measured by the given `Comparator`. + /// + /// @param Type of elements in the input and output stream + /// @param comparator A non-null `Comparator` to compare stream elements + /// @return A non-null gatherer + public static Gatherer filterDecreasing(final Comparator comparator) { + return FilterChangingGatherer.usingComparator(ChangingOperation.Decreasing, comparator); + } + + /// Filter the input stream so that it contains `Comparable` elements in a strictly increasing order. + /// + /// @param Type of elements in the input and output stream + /// @return A non-null gatherer + public static > Gatherer filterIncreasing() { + return FilterChangingGatherer.usingComparable(ChangingOperation.Increasing); + } + + /// Filter the input stream so that it contains elements in a strictly increasing order as measured by the given `Comparator`. + /// + /// @param Type of elements in the input and output stream + /// @param comparator A non-null `Comparator` to compare stream elements + /// @return A non-null gatherer + public static Gatherer filterIncreasing(final Comparator comparator) { + return FilterChangingGatherer.usingComparator(ChangingOperation.Increasing, comparator); + } + /// Filter the elements in the stream to only include elements of the given types. /// Note, due to how generics work you may end up with some... interesting stream types as a result /// @@ -156,6 +271,40 @@ public abstract class Gatherers4j { return TypeFilteringGatherer.of(validTypes); } + /// Filter the input stream so that it contains `Comparable` elements in a non-decreasing order. + /// + /// @param Type of elements in the input and output stream + /// @return A non-null gatherer + public static > Gatherer filterNonDecreasing() { + return FilterChangingGatherer.usingComparable(ChangingOperation.NonDecreasing); + } + + /// Filter the input stream so that it contains elements in a non-decreasing order as measured by the given `Comparator`. + /// + /// @param Type of elements in the input and output stream + /// @param comparator A non-null `Comparator` to compare stream elements + /// @return A non-null gatherer + public static Gatherer filterNonDecreasing(final Comparator comparator) { + return FilterChangingGatherer.usingComparator(ChangingOperation.NonDecreasing, comparator); + } + + /// Filter the input stream so that it contains `Comparable` elements in a non-increasing order. + /// + /// @param Type of elements in the input and output stream + /// @return A non-null gatherer + public static > Gatherer filterNonIncreasing() { + return FilterChangingGatherer.usingComparable(ChangingOperation.NonIncreasing); + } + + /// Filter the input stream so that it contains elements in a non-increasing order as measured by the given `Comparator`. + /// + /// @param Type of elements in the input and output stream + /// @param comparator A non-null `Comparator` to compare stream elements + /// @return A non-null gatherer + public static Gatherer filterNonIncreasing(final Comparator comparator) { + return FilterChangingGatherer.usingComparator(ChangingOperation.NonIncreasing, comparator); + } + /// Filter a stream according to the given `predicate`, which takes both the item being examined, /// and its index. /// @@ -183,6 +332,146 @@ public abstract class Gatherers4j { return new AccumulatingGatherer<>(false, initialValue, foldFunction); } + /// Convert the input stream of `Comparable` objects into lists of strictly decreasing objects. The lists + /// emitted to the output stream are unmodifiable. + /// + /// ```java + /// Stream.of(3, 2, 1, 3, 2) + /// .gather(groupDecreasing()) + /// .toList(); + /// + /// // [[3, 2, 1], [3, 2]] + /// ``` + /// + /// @param Type of elements in the input stream + /// @return A non-null Gatherer + public static > GroupChangingGatherer groupDecreasing() { + return GroupChangingGatherer.usingComparable(ChangingOperation.Decreasing); + } + + /// Convert the input stream of objects into lists of strictly decreasing objects, as measured by the given `Comparator`. + /// The lists emitted to the output stream are unmodifiable. + /// + /// ```java + /// Stream.of("ABC", "AB", "A", "ABC", "AB") + /// .gather(groupDecreasing(Comparator.comparingInt(String::length))) + /// .toList(); + /// + /// // [["ABC", "AB", "A"], ["ABC", "AB"]] + /// ``` + /// + /// @param Type of elements in the input stream + /// @param comparator The non-null comparator used to compare stream elements + /// @return A non-null Gatherer + public static GroupChangingGatherer groupDecreasing(final Comparator comparator) { + return GroupChangingGatherer.usingComparator(ChangingOperation.Decreasing, comparator); + } + + /// Convert the input stream of `Comparable` objects into lists of strictly increasing objects. The lists + /// emitted to the output stream are unmodifiable. + /// + /// ```java + /// Stream.of(1, 2, 3, 2, 3) + /// .gather(groupIncreasing()) + /// .toList(); + /// + /// // [[1, 2, 3], [2, 3]] + /// ``` + /// + /// @param Type of elements in the input stream + /// @return A non-null Gatherer + public static > GroupChangingGatherer groupIncreasing() { + return GroupChangingGatherer.usingComparable(ChangingOperation.Increasing); + } + + /// Convert the input stream of objects into lists of strictly increasing objects, as measured by the given `Comparator`. + /// The lists emitted to the output stream are unmodifiable. + /// + /// ```java + /// Stream.of("A", "AB", "ABC", "AB", "ABC") + /// .gather(increasing(Comparator.comparingInt(String::length))) + /// .toList(); + /// + /// // [["A", "AB", "ABC"], ["AB", "ABC"]] + /// ``` + /// + /// @param Type of elements in the input stream + /// @param comparator The non-null comparator used to compare stream elements + /// @return A non-null Gatherer + public static GroupChangingGatherer groupIncreasing(final Comparator comparator) { + return GroupChangingGatherer.usingComparator(ChangingOperation.Increasing, comparator); + } + + /// Convert the input stream of `Comparable` objects into lists of non-decreasing objects. The lists + /// emitted to the output stream are unmodifiable. + /// + /// ```java + /// Stream.of(2, 3, 3, 2, 3) + /// .gather(groupNonDecreasing()) + /// .toList(); + /// + /// // [[2, 3, 3], [2, 3]] + /// ``` + /// + /// @param Type of elements in the input stream + /// @return A non-null Gatherer + public static > GroupChangingGatherer groupNonDecreasing() { + return GroupChangingGatherer.usingComparable(ChangingOperation.NonDecreasing); + } + + /// Convert the input stream of objects into lists of non-decreasing objects, as measured by the given `Comparator`. + /// The lists emitted to the output stream are unmodifiable. + /// + /// ```java + /// Stream.of("A", "AB", "AB", "A", "AB") + /// .gather(decreasing(Comparator.comparingInt(String::length))) + /// .toList(); + /// + /// // [["A", "AB", "AB"], ["A", "AB"]] + /// ``` + /// + /// @param Type of elements in the input stream + /// @param comparator The non-null comparator used to compare stream elements + /// @return A non-null Gatherer + public static GroupChangingGatherer groupNonDecreasing(final Comparator comparator) { + return GroupChangingGatherer.usingComparator(ChangingOperation.NonDecreasing, comparator); + } + + /// Convert the input stream of `Comparable` objects into lists of non-increasing objects. The lists + /// emitted to the output stream are unmodifiable. + /// + /// ```java + /// Stream.of(3, 2, 2, 3, 2) + /// .gather(groupNonIncreasing()) + /// .toList(); + /// + /// // [[3, 2, 2], [3, 2]] + /// ``` + /// + /// @param Type of elements in the input stream + /// @return A non-null Gatherer + public static > GroupChangingGatherer groupNonIncreasing() { + return GroupChangingGatherer.usingComparable(ChangingOperation.NonIncreasing); + } + + /// Convert the input stream of objects into lists of non-increasing objects, as measured by the given `Comparator`. + /// The lists emitted to the output stream are unmodifiable. + /// + /// ```java + /// Stream.of("ABC", "AB", "AB", "ABC", "AB") + /// .gather(nonIncreasing(Comparator.comparingInt(String::length))) + /// .toList(); + /// + /// // [["ABC", "AB", "AB"], ["ABC", "AB"]] + /// ``` + /// + /// @param Type of elements in the input stream + /// @param comparator The non-null comparator used to compare stream elements + /// @return A non-null Gatherer + public static GroupChangingGatherer groupNonIncreasing(final Comparator comparator) { + return GroupChangingGatherer.usingComparator(ChangingOperation.NonIncreasing, comparator); + } + /// Turn a `Stream` into a `Stream>` where consecutive /// equal elements, where equality is measured by `Object.equals(Object)`. /// @@ -484,26 +773,6 @@ public static LastGatherer last(final int count) { return new ShufflingGatherer<>(randomGenerator); } - /// Create a Stream that is the running average of `Stream` - /// - /// @return BigDecimalSimpleAverageGatherer - public static BigDecimalSimpleAverageGatherer<@Nullable BigDecimal> simpleRunningAverage() { - return simpleRunningAverageBy(Function.identity()); - } - - /// Create a Stream that is the running average of `BigDecimal` objects as mapped by - /// the given function. This is useful when paired with the `withOriginal` function. - /// - /// @param mappingFunction A function to map `` objects to `BigDecimal`, the results of which will be used - /// in the running average calculation - /// @param Type of elements in the input stream, to be remapped to `BigDecimal` by the `mappingFunction` - /// @return A non-null `BigDecimalSimpleAverageGatherer` - public static BigDecimalSimpleAverageGatherer simpleRunningAverageBy( - final Function mappingFunction - ) { - return new BigDecimalSimpleAverageGatherer<>(mappingFunction); - } - /// Create a Stream that represents the simple moving average of a `Stream` looking /// back `windowSize` number of elements. /// @@ -528,6 +797,26 @@ public static LastGatherer last(final int count) { return new BigDecimalSimpleMovingAverageGatherer<>(windowSize, mappingFunction); } + /// Create a Stream that is the running average of `Stream` + /// + /// @return BigDecimalSimpleAverageGatherer + public static BigDecimalSimpleAverageGatherer<@Nullable BigDecimal> simpleRunningAverage() { + return simpleRunningAverageBy(Function.identity()); + } + + /// Create a Stream that is the running average of `BigDecimal` objects as mapped by + /// the given function. This is useful when paired with the `withOriginal` function. + /// + /// @param mappingFunction A function to map `` objects to `BigDecimal`, the results of which will be used + /// in the running average calculation + /// @param Type of elements in the input stream, to be remapped to `BigDecimal` by the `mappingFunction` + /// @return A non-null `BigDecimalSimpleAverageGatherer` + public static BigDecimalSimpleAverageGatherer simpleRunningAverageBy( + final Function mappingFunction + ) { + return new BigDecimalSimpleAverageGatherer<>(mappingFunction); + } + /// Ensure the input stream is exactly `size` elements long, and emit all elements if so. /// If not, throw an `IllegalStateException`. /// diff --git a/src/main/java/com/ginsberg/gatherers4j/GroupChangingGatherer.java b/src/main/java/com/ginsberg/gatherers4j/GroupChangingGatherer.java new file mode 100644 index 0000000..e16e1a0 --- /dev/null +++ b/src/main/java/com/ginsberg/gatherers4j/GroupChangingGatherer.java @@ -0,0 +1,91 @@ +/* + * Copyright 2025 Todd Ginsberg + * + * 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 + * + * http://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 com.ginsberg.gatherers4j; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.function.BiConsumer; +import java.util.function.Supplier; +import java.util.stream.Gatherer; + +import static com.ginsberg.gatherers4j.GathererUtils.mustNotBeNull; + +public class GroupChangingGatherer + implements Gatherer, List> { + + private final ChangingOperation operation; + private final Comparator comparator; + + static GroupChangingGatherer usingComparator( + final ChangingOperation operation, + final Comparator comparator + ) { + return new GroupChangingGatherer<>(operation, comparator); + } + + static > GroupChangingGatherer usingComparable( + final ChangingOperation operation + ) { + return new GroupChangingGatherer<>(operation, Comparable::compareTo); + } + + GroupChangingGatherer( + final ChangingOperation operation, + final Comparator comparator + ) { + mustNotBeNull(operation, "Operation must not be null"); + mustNotBeNull(comparator, "Comparator must not be null"); + this.operation = operation; + this.comparator = comparator; + } + + @Override + public Supplier> initializer() { + return State::new; + } + + @Override + public Integrator, INPUT, List> integrator() { + return Integrator.ofGreedy((state, element, downstream) -> { + if (!state.currentElements.isEmpty() && !isInSameList(state.currentElements.getLast(), element)) { + downstream.push(List.copyOf(state.currentElements)); + state.currentElements = new ArrayList<>(); + } + state.currentElements.add(element); + return !downstream.isRejecting(); + }); + } + + boolean isInSameList(final INPUT previous, final INPUT next) { + return operation.allows(comparator.compare(next, previous)); + } + + @Override + public BiConsumer, Downstream>> finisher() { + return (inputState, downstream) -> { + if (!inputState.currentElements.isEmpty()) { + downstream.push(List.copyOf(inputState.currentElements)); + } + }; + } + + public static class State { + List currentElements = new ArrayList<>(); + } + +} diff --git a/src/test/java/com/ginsberg/gatherers4j/FilterChangingGathererTest.java b/src/test/java/com/ginsberg/gatherers4j/FilterChangingGathererTest.java new file mode 100644 index 0000000..136f9de --- /dev/null +++ b/src/test/java/com/ginsberg/gatherers4j/FilterChangingGathererTest.java @@ -0,0 +1,418 @@ +/* + * Copyright 2025 Todd Ginsberg + * + * 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 + * + * http://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 com.ginsberg.gatherers4j; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.Comparator; +import java.util.List; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class FilterChangingGathererTest { + + @Nested + class WithComparable { + + @Nested + class Decreasing { + @Test + void decreasing() { + // Arrange + final Stream input = Stream.of(3, 2, 2, 1); + + // Act + final List output = input + .gather(Gatherers4j.filterDecreasing()) + .toList(); + + // Assert + assertThat(output).containsExactly(3, 2, 1); + } + + @Test + void emptyStream() { + // Arrange + final Stream input = Stream.empty(); + + // Act + final List output = input + .gather(Gatherers4j.filterDecreasing()) + .toList(); + + // Assert + assertThat(output).isEmpty(); + } + + @Test + void singleElementStream() { + // Arrange + final Stream input = Stream.of(1); + + // Act + final List output = input + .gather(Gatherers4j.filterDecreasing()) + .toList(); + + // Assert + assertThat(output).containsExactly(1); + } + } + + @Nested + class Increasing { + @Test + void emptyStream() { + // Arrange + final Stream input = Stream.empty(); + + // Act + final List output = input + .gather(Gatherers4j.filterIncreasing()) + .toList(); + + // Assert + assertThat(output).isEmpty(); + } + + @Test + void increasing() { + // Arrange + final Stream input = Stream.of(1, 2, 2, 3); + + // Act + final List output = input + .gather(Gatherers4j.filterIncreasing()) + .toList(); + + // Assert + assertThat(output).containsExactly(1, 2, 3); + } + + @Test + void singleElementStream() { + // Arrange + final Stream input = Stream.of(1); + + // Act + final List output = input + .gather(Gatherers4j.filterIncreasing()) + .toList(); + + // Assert + assertThat(output).containsExactly(1); + } + } + + @Nested + class NonDecreasing { + @Test + void emptyStream() { + // Arrange + final Stream input = Stream.empty(); + + // Act + final List output = input + .gather(Gatherers4j.filterNonDecreasing()) + .toList(); + + // Assert + assertThat(output).isEmpty(); + } + + @Test + void nonDecreasing() { + // Arrange + final Stream input = Stream.of(1, 2, 2, 3, 2); + + // Act + final List output = input + .gather(Gatherers4j.filterNonDecreasing()) + .toList(); + + // Assert + assertThat(output).containsExactly(1, 2, 2, 3); + } + + @Test + void singleElementStream() { + // Arrange + final Stream input = Stream.of(1); + + // Act + final List output = input + .gather(Gatherers4j.filterNonDecreasing()) + .toList(); + + // Assert + assertThat(output).containsExactly(1); + } + } + + @Nested + class NonIncreasing { + @Test + void emptyStream() { + // Arrange + final Stream input = Stream.empty(); + + // Act + final List output = input + .gather(Gatherers4j.filterNonIncreasing()) + .toList(); + + // Assert + assertThat(output).isEmpty(); + } + + @Test + void nonIncreasing() { + // Arrange + final Stream input = Stream.of(3, 2, 2, 1, 2); + + // Act + final List output = input + .gather(Gatherers4j.filterNonIncreasing()) + .toList(); + + // Assert + assertThat(output).containsExactly(3, 2, 2, 1); + } + + @Test + void singleElementStream() { + // Arrange + final Stream input = Stream.of(1); + + // Act + final List output = input + .gather(Gatherers4j.filterNonIncreasing()) + .toList(); + + // Assert + assertThat(output).containsExactly(1); + } + } + } + + @Nested + class WithComparator { + + @Nested + class Common { + @SuppressWarnings("DataFlowIssue") + @Test + void comparatorMustNotBeNull() { + assertThatThrownBy(() -> + new FilterChangingGatherer<>(ChangingOperation.Increasing, null) + ).isExactlyInstanceOf(IllegalArgumentException.class); + } + + @SuppressWarnings("DataFlowIssue") + @Test + void operationMustNotBeNull() { + assertThatThrownBy(() -> + new FilterChangingGatherer<>(null, (_, _) -> 0) + ).isExactlyInstanceOf(IllegalArgumentException.class); + } + + } + + @Nested + class Decreasing { + @Test + void decreasing() { + // Arrange + final Stream input = Stream.of("AAA", "AA", "AA", "A"); + + // Act + final List output = input + .gather(Gatherers4j.filterDecreasing(Comparator.comparingInt(String::length))) + .toList(); + + // Assert + assertThat(output).containsExactly("AAA", "AA", "A"); + } + + @Test + void emptyStream() { + // Arrange + final Stream input = Stream.empty(); + + // Act + final List output = input + .gather(Gatherers4j.filterDecreasing(Comparator.comparingInt(String::length))) + .toList(); + + // Assert + assertThat(output).isEmpty(); + } + + @Test + void singleElementStream() { + // Arrange + final Stream input = Stream.of("A"); + + // Act + final List output = input + .gather(Gatherers4j.filterDecreasing(Comparator.comparingInt(String::length))) + .toList(); + + // Assert + assertThat(output).containsExactly("A"); + } + } + + @Nested + class Increasing { + @Test + void emptyStream() { + // Arrange + final Stream input = Stream.empty(); + + // Act + final List output = input + .gather(Gatherers4j.filterIncreasing(Comparator.comparingInt(String::length))) + .toList(); + + // Assert + assertThat(output).isEmpty(); + } + + @Test + void increasing() { + // Arrange + final Stream input = Stream.of("A", "AA", "AA", "AAA"); + + // Act + final List output = input + .gather(Gatherers4j.filterIncreasing(Comparator.comparingInt(String::length))) + .toList(); + + // Assert + assertThat(output).containsExactly("A", "AA", "AAA"); + } + + @Test + void singleElementStream() { + // Arrange + final Stream input = Stream.of("A"); + + // Act + final List output = input + .gather(Gatherers4j.filterIncreasing(Comparator.comparingInt(String::length))) + .toList(); + + // Assert + assertThat(output).containsExactly("A"); + } + } + + @Nested + class NonDecreasing { + @Test + void emptyStream() { + // Arrange + final Stream input = Stream.empty(); + + // Act + final List output = input + .gather(Gatherers4j.filterNonDecreasing(Comparator.comparingInt(String::length))) + .toList(); + + // Assert + assertThat(output).isEmpty(); + } + + @Test + void nonDecreasing() { + // Arrange + final Stream input = Stream.of("A", "AA", "AA", "AAA", "AA"); + + // Act + final List output = input + .gather(Gatherers4j.filterNonDecreasing(Comparator.comparingInt(String::length))) + .toList(); + + // Assert + assertThat(output).containsExactly("A", "AA", "AA", "AAA"); + } + + @Test + void singleElementStream() { + // Arrange + final Stream input = Stream.of("A"); + + // Act + final List output = input + .gather(Gatherers4j.filterNonDecreasing(Comparator.comparingInt(String::length))) + .toList(); + + // Assert + assertThat(output).containsExactly("A"); + } + } + + @Nested + class NonIncreasing { + @Test + void emptyStream() { + // Arrange + final Stream input = Stream.empty(); + + // Act + final List output = input + .gather(Gatherers4j.filterNonIncreasing(Comparator.comparingInt(String::length))) + .toList(); + + // Assert + assertThat(output).isEmpty(); + } + + @Test + void nonIncreasing() { + // Arrange + final Stream input = Stream.of("AAA", "AA", "AA", "A", "AA"); + + // Act + final List output = input + .gather(Gatherers4j.filterNonIncreasing(Comparator.comparingInt(String::length))) + .toList(); + + // Assert + assertThat(output).containsExactly("AAA", "AA", "AA", "A"); + } + + @Test + void singleElementStream() { + // Arrange + final Stream input = Stream.of("A"); + + // Act + final List output = input + .gather(Gatherers4j.filterNonIncreasing(Comparator.comparingInt(String::length))) + .toList(); + + // Assert + assertThat(output).containsExactly("A"); + } + } + } +} \ No newline at end of file diff --git a/src/test/java/com/ginsberg/gatherers4j/FlattenSingleOrFailTest.java b/src/test/java/com/ginsberg/gatherers4j/FlattenSingleOrFailTest.java new file mode 100644 index 0000000..4c68885 --- /dev/null +++ b/src/test/java/com/ginsberg/gatherers4j/FlattenSingleOrFailTest.java @@ -0,0 +1,93 @@ +/* + * Copyright 2025 Todd Ginsberg + * + * 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 + * + * http://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 com.ginsberg.gatherers4j; + +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class FlattenSingleOrFailTest { + + @Test + void doesNotEmitAnythingDuringFailureCase() { + // Arrange + final Stream> input = Stream.of(List.of("A"), List.of("B")); + final Set emitted = new HashSet<>(); + + // Act + assertThatThrownBy(() -> + input.gather(new FlattenSingleOrFail<>("More than one input collection")) + .peek(emitted::add) + .toList() + ).isExactlyInstanceOf(IllegalStateException.class); + + // Assert + assertThat(emitted).isEmpty(); + } + + @Test + void emitsListWhenSinglePresent() { + // Arrange + final Stream> input = Stream.of(List.of("A", "B")); + + // Act + final List output = input.gather(new FlattenSingleOrFail<>("More than one input collection")).toList(); + + // Assert + assertThat(output).containsExactly("A", "B"); + } + + @Test + void emptyStream() { + // Arrange + final Stream> input = Stream.empty(); + + // Act + final List output = input.gather(new FlattenSingleOrFail<>("More than one input collection")).toList(); + + // Assert + assertThat(output).isEmpty(); + } + + @Test + void failsWhenThereAreMoreThanOneList() { + assertThatThrownBy(() -> + Stream.of(List.of("A"), Set.of("A")) + .gather(new FlattenSingleOrFail<>("More than one input collection")) + .toList() + ).isExactlyInstanceOf(IllegalStateException.class); + } + + @SuppressWarnings("DataFlowIssue") + @Test + void singleElementNull() { + // Arrange + final Stream> input = Stream.of((List) null); + + // Act + final List output = input.gather(new FlattenSingleOrFail<>("More than one input collection")).toList(); + + // Assert + assertThat(output).isEmpty(); + } +} \ No newline at end of file diff --git a/src/test/java/com/ginsberg/gatherers4j/GroupChangingGathererTest.java b/src/test/java/com/ginsberg/gatherers4j/GroupChangingGathererTest.java new file mode 100644 index 0000000..9ef05b5 --- /dev/null +++ b/src/test/java/com/ginsberg/gatherers4j/GroupChangingGathererTest.java @@ -0,0 +1,643 @@ +/* + * Copyright 2025 Todd Ginsberg + * + * 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 + * + * http://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 com.ginsberg.gatherers4j; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.Comparator; +import java.util.List; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class GroupChangingGathererTest { + + @Nested + class WithComparable { + @Nested + class Decreasing { + + @Test + void decreasing() { + // Arrange + final Stream input = Stream.of(3, 2, 1, 2, 2, 1); + + // Act + final List> output = input + .gather(Gatherers4j.groupDecreasing()) + .toList(); + + // Assert + assertThat(output) + .containsExactly( + List.of(3, 2, 1), + List.of(2), + List.of(2, 1) + ); + } + + @Test + void emptyStream() { + // Arrange + final Stream input = Stream.empty(); + + // Act + final List> output = input + .gather(Gatherers4j.groupDecreasing()) + .toList(); + + // Assert + assertThat(output).isEmpty(); + } + + @Test + void ensureDecreasing() { + // Arrange + final Stream input = Stream.of(4, 3, 2, 1); + + // Act + final List output = input.gather(Gatherers4j.ensureDecreasing()).toList(); + + // Assert + assertThat(output).containsExactly(4, 3, 2, 1); + } + + @Test + void ensureDecreasingFailureCase() { + assertThatThrownBy(() -> + Stream.of(1, 1).gather(Gatherers4j.ensureDecreasing()).toList() + ).isExactlyInstanceOf(IllegalStateException.class); + } + + @Test + void singleElementStream() { + // Arrange + final Stream input = Stream.of("A"); + + // Act + final List> output = input + .gather(Gatherers4j.groupDecreasing()) + .toList(); + + // Assert + assertThat(output).containsExactly(List.of("A")); + } + } + + @Nested + class Increasing { + @Test + void emptyStream() { + // Arrange + final Stream input = Stream.empty(); + + // Act + final List> output = input + .gather(Gatherers4j.groupIncreasing()) + .toList(); + + // Assert + assertThat(output).isEmpty(); + } + + @Test + void ensureIncreasing() { + // Arrange + final Stream input = Stream.of(1, 2, 3, 4); + + // Act + final List output = input.gather(Gatherers4j.ensureIncreasing()).toList(); + + // Assert + assertThat(output).containsExactly(1, 2, 3, 4); + } + + @Test + void ensureIncreasingFailureCase() { + assertThatThrownBy(() -> + Stream.of(1, 1).gather(Gatherers4j.ensureIncreasing()).toList() + ).isExactlyInstanceOf(IllegalStateException.class); + } + + @Test + void increasing() { + // Arrange + final Stream input = Stream.of(1, 2, 3, 3, 2, 3); + + // Act + final List> output = input + .gather(Gatherers4j.groupIncreasing()) + .toList(); + + // Assert + assertThat(output) + .containsExactly( + List.of(1, 2, 3), + List.of(3), + List.of(2, 3) + ); + } + + @Test + void singleElementStream() { + // Arrange + final Stream input = Stream.of("A"); + + // Act + final List> output = input + .gather(Gatherers4j.groupIncreasing()) + .toList(); + + // Assert + assertThat(output).containsExactly(List.of("A")); + } + } + + @Nested + class NonDecreasing { + @Test + void emptyStream() { + // Arrange + final Stream input = Stream.empty(); + + // Act + final List> output = input + .gather(Gatherers4j.groupNonDecreasing()) + .toList(); + + // Assert + assertThat(output).isEmpty(); + } + + @Test + void ensureNonDecreasing() { + // Arrange + final Stream input = Stream.of(4, 4, 5, 6); + + // Act + final List output = input.gather(Gatherers4j.ensureNonDecreasing()).toList(); + + // Assert + assertThat(output).containsExactly(4, 4, 5, 6); + } + + @Test + void ensureNonDecreasingFailureCase() { + assertThatThrownBy(() -> + Stream.of(1, 0).gather(Gatherers4j.ensureNonDecreasing()).toList() + ).isExactlyInstanceOf(IllegalStateException.class); + } + + @Test + void nonDecreasing() { + // Arrange + final Stream input = Stream.of(1, 2, 3, 3, 2, 3); + + // Act + final List> output = input + .gather(Gatherers4j.groupNonDecreasing()) + .toList(); + + // Assert + assertThat(output) + .containsExactly( + List.of(1, 2, 3, 3), + List.of(2, 3) + ); + } + + @Test + void singleElementStream() { + // Arrange + final Stream input = Stream.of("A"); + + // Act + final List> output = input + .gather(Gatherers4j.groupNonDecreasing()) + .toList(); + + // Assert + assertThat(output).containsExactly(List.of("A")); + } + } + + @Nested + class NonIncreasing { + @Test + void emptyStream() { + // Arrange + final Stream input = Stream.empty(); + + // Act + final List> output = input + .gather(Gatherers4j.groupNonIncreasing()) + .toList(); + + // Assert + assertThat(output).isEmpty(); + } + + @Test + void ensureNonIncreasing() { + // Arrange + final Stream input = Stream.of(4, 3, 2, 2); + + // Act + final List output = input.gather(Gatherers4j.ensureNonIncreasing()).toList(); + + // Assert + assertThat(output).containsExactly(4, 3, 2, 2); + } + + @Test + void ensureNonIncreasingFailureCase() { + assertThatThrownBy(() -> + Stream.of(1, 2).gather(Gatherers4j.ensureNonIncreasing()).toList() + ).isExactlyInstanceOf(IllegalStateException.class); + } + + @Test + void nonIncreasing() { + // Arrange + final Stream input = Stream.of(3, 2, 1, 2, 2, 1); + + // Act + final List> output = input + .gather(Gatherers4j.groupNonIncreasing()) + .toList(); + + // Assert + assertThat(output) + .containsExactly( + List.of(3, 2, 1), + List.of(2, 2, 1) + ); + } + + @Test + void singleElementStream() { + // Arrange + final Stream input = Stream.of("A"); + + // Act + final List> output = input + .gather(Gatherers4j.groupNonIncreasing()) + .toList(); + + // Assert + assertThat(output).containsExactly(List.of("A")); + } + + } + } + + @Nested + class WithComparator { + + @Nested + class Common { + @SuppressWarnings("DataFlowIssue") + @Test + void comparatorMustNotBeNull() { + assertThatThrownBy(() -> + new GroupChangingGatherer<>(ChangingOperation.Increasing, null) + ).isExactlyInstanceOf(IllegalArgumentException.class); + } + + @SuppressWarnings("DataFlowIssue") + @Test + void operationMustNotBeNull() { + assertThatThrownBy(() -> + new GroupChangingGatherer<>(null, (_, _) -> 0) + ).isExactlyInstanceOf(IllegalArgumentException.class); + } + + @Test + void returnedListUnmodifiable() { + // Arrange + final Stream input = Stream.of("A", "B", "C"); + + // Act + final List> output = input + .gather(new GroupChangingGatherer<>(ChangingOperation.Increasing, Comparator.comparing(String::length))) + .toList(); + + // Assert + assertThat(output).hasSize(3); + output.forEach(it -> + assertThatThrownBy(() -> + it.add("D") + ).isInstanceOf(UnsupportedOperationException.class) + ); + } + } + + @Nested + class Decreasing { + @Test + void decreasing() { + // Arrange + final Stream input = Stream.of("AAA", "AA", "A", "AA", "AA", "A"); + + // Act + final List> output = input + .gather(Gatherers4j.groupDecreasing(Comparator.comparingInt(String::length))) + .toList(); + + // Assert + assertThat(output) + .containsExactly( + List.of("AAA", "AA", "A"), + List.of("AA"), + List.of("AA", "A") + ); + } + + @Test + void emptyStream() { + // Arrange + final Stream input = Stream.empty(); + + // Act + final List> output = input + .gather(Gatherers4j.groupDecreasing(Comparator.comparingInt(String::length))) + .toList(); + + // Assert + assertThat(output).isEmpty(); + } + + @Test + void ensureDecreasing() { + // Arrange + final Stream input = Stream.of("AAA", "AA", "A"); + + // Act + final List output = input + .gather(Gatherers4j.ensureDecreasing(Comparator.comparingInt(String::length))) + .toList(); + + // Assert + assertThat(output).containsExactly("AAA", "AA", "A"); + } + + @Test + void ensureDecreasingFailureCase() { + assertThatThrownBy(() -> + Stream.of("A", "AA") + .gather(Gatherers4j.ensureDecreasing(Comparator.comparingInt(String::length))) + .toList() + ).isExactlyInstanceOf(IllegalStateException.class); + } + + @Test + void singleElementStream() { + // Arrange + final Stream input = Stream.of("A"); + + // Act + final List> output = input + .gather(Gatherers4j.groupDecreasing(Comparator.comparingInt(String::length))) + .toList(); + + // Assert + assertThat(output).containsExactly(List.of("A")); + } + } + + @Nested + class Increasing { + @Test + void emptyStream() { + // Arrange + final Stream input = Stream.empty(); + + // Act + final List> output = input + .gather(Gatherers4j.groupIncreasing(Comparator.comparingInt(String::length))) + .toList(); + + // Assert + assertThat(output).isEmpty(); + } + + @Test + void ensureIncreasing() { + // Arrange + final Stream input = Stream.of("A", "AA", "AAA"); + + // Act + final List output = input + .gather(Gatherers4j.ensureIncreasing(Comparator.comparingInt(String::length))) + .toList(); + + // Assert + assertThat(output).containsExactly("A", "AA", "AAA"); + } + + @Test + void ensureIncreasingFailureCase() { + assertThatThrownBy(() -> + Stream.of("AA", "A") + .gather(Gatherers4j.ensureIncreasing(Comparator.comparingInt(String::length))) + .toList() + ).isExactlyInstanceOf(IllegalStateException.class); + } + + @Test + void increasing() { + // Arrange + final Stream input = Stream.of("A", "AA", "AAA", "AAA", "AA", "AAA"); + + // Act + final List> output = input + .gather(Gatherers4j.groupIncreasing(Comparator.comparingInt(String::length))) + .toList(); + + // Assert + assertThat(output) + .containsExactly( + List.of("A", "AA", "AAA"), + List.of("AAA"), + List.of("AA", "AAA") + ); + } + + @Test + void singleElementStream() { + // Arrange + final Stream input = Stream.of("A"); + + // Act + final List> output = input + .gather(Gatherers4j.groupIncreasing(Comparator.comparingInt(String::length))) + .toList(); + + // Assert + assertThat(output).containsExactly(List.of("A")); + } + } + + @Nested + class NonDecreasing { + + @Test + void emptyStream() { + // Arrange + final Stream input = Stream.empty(); + + // Act + final List> output = input + .gather(Gatherers4j.groupNonDecreasing(Comparator.comparingInt(String::length))) + .toList(); + + // Assert + assertThat(output).isEmpty(); + } + + @Test + void ensureNonDecreasing() { + // Arrange + final Stream input = Stream.of("A", "A", "A"); + + // Act + final List output = input + .gather(Gatherers4j.ensureNonDecreasing(Comparator.comparingInt(String::length))) + .toList(); + + // Assert + assertThat(output).containsExactly("A", "A", "A"); + } + + @Test + void ensureNonDecreasingFailureCase() { + assertThatThrownBy(() -> + Stream.of("AA", "A") + .gather(Gatherers4j.ensureNonDecreasing(Comparator.comparingInt(String::length))) + .toList() + ).isExactlyInstanceOf(IllegalStateException.class); + } + + @Test + void nonDecreasing() { + // Arrange + final Stream input = Stream.of("A", "AA", "AAA", "AAA", "AA", "AAA"); + + // Act + final List> output = input + .gather(Gatherers4j.groupNonDecreasing(Comparator.comparingInt(String::length))) + .toList(); + + // Assert + assertThat(output) + .containsExactly( + List.of("A", "AA", "AAA", "AAA"), + List.of("AA", "AAA") + ); + } + + @Test + void singleElementStream() { + // Arrange + final Stream input = Stream.of("A"); + + // Act + final List> output = input + .gather(Gatherers4j.groupNonDecreasing(Comparator.comparingInt(String::length))) + .toList(); + + // Assert + assertThat(output).containsExactly(List.of("A")); + } + } + + @Nested + class NonIncreasing { + @Test + void emptyStream() { + // Arrange + final Stream input = Stream.empty(); + + // Act + final List> output = input + .gather(Gatherers4j.groupNonIncreasing(Comparator.comparingInt(String::length))) + .toList(); + + // Assert + assertThat(output).isEmpty(); + } + + @Test + void ensureNonIncreasing() { + // Arrange + final Stream input = Stream.of("A", "A", "A"); + + // Act + final List output = input + .gather(Gatherers4j.ensureNonIncreasing(Comparator.comparingInt(String::length))) + .toList(); + + // Assert + assertThat(output).containsExactly("A", "A", "A"); + } + + @Test + void ensureNonIncreasingFailureCase() { + assertThatThrownBy(() -> + Stream.of("AA", "AAA") + .gather(Gatherers4j.ensureNonIncreasing(Comparator.comparingInt(String::length))) + .toList() + ).isExactlyInstanceOf(IllegalStateException.class); + } + + @Test + void nonIncreasing() { + // Arrange + final Stream input = Stream.of("AAA", "AA", "A", "AA", "AA", "A"); + + // Act + final List> output = input + .gather(Gatherers4j.groupNonIncreasing(Comparator.comparingInt(String::length))) + .toList(); + + // Assert + assertThat(output) + .containsExactly( + List.of("AAA", "AA", "A"), + List.of("AA", "AA", "A") + ); + } + + @Test + void singleElementStream() { + // Arrange + final Stream input = Stream.of("A"); + + // Act + final List> output = input + .gather(Gatherers4j.groupNonIncreasing(Comparator.comparingInt(String::length))) + .toList(); + + // Assert + assertThat(output).containsExactly(List.of("A")); + } + } + } + +} \ No newline at end of file