diff --git a/CHANGELOG.md b/CHANGELOG.md index 61063a2..5216d20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ### 0.5.0 + Implement `reverse()` ++ Implement `maxBy(fn)` and `minBy(fn)` ### 0.4.0 + Implement `suffle()` and `shuffle(RandomGenerator)` diff --git a/README.md b/README.md index 315542a..f9ce081 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,8 @@ implementation("com.ginsberg:gatherers4j:0.5.0") | `filterWithIndex(predicate)` | Filter the stream with the given `predicate`, which takes an `element` and its `index` | | `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 | +| `maxBy(fn)` | Return a stream containing a single element, which is the maximum value returned by the mapping function `fn` | +| `minBy(fn)` | Return a stream containing a single element, which is the minimum value returned by the mapping function `fn` | | `reverse()` | Reverse the order of the stream | | `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` | @@ -168,6 +170,30 @@ Stream // ["E", "F", "G"] ``` +#### Find the object with the maximum mapped value + +```java +record Employee(String name, int salary) {} + +streamOfEmployees + .gather(Gatherers4j.maxBy(Employee:salary)) + .toList(); + +// Employee("Big Shot", 1_000_000) +``` + +#### Find the object with the minimum mapped value + +```java +record Person(String name, int age) {} + +streamOfPeople + .gather(Gatherers4j.minBy(Person:age)) + .toList(); + +// Person("Baby", 1) +``` + #### Reverse the order of the stream ```java diff --git a/src/main/java/com/ginsberg/gatherers4j/Gatherers4j.java b/src/main/java/com/ginsberg/gatherers4j/Gatherers4j.java index 466c86c..f366c44 100644 --- a/src/main/java/com/ginsberg/gatherers4j/Gatherers4j.java +++ b/src/main/java/com/ginsberg/gatherers4j/Gatherers4j.java @@ -106,6 +106,34 @@ public static LastGatherer last(final int count) { return new LastGatherer<>(count); } + /** + * Return a Stream containing the single maximum value of the input stream, according to + * the given mapping function. In the case where a stream has more than one mapped value + * that is the maximum, the first one encountered makes up the stream. This does not + * evaluate null values or null mappings. + * + * @param function A mapping function, the results of which must implement Comparable + */ + public static > MinMaxGatherer maxBy( + final Function function + ) { + return new MinMaxGatherer<>(true, function); + } + + /** + * Return a Stream containing the single minimum value of the input stream, according to + * the given mapping function. In the case where a stream has more than one mapped value + * that is the minimum, the first one encountered makes up the stream. This does not + * evaluate null values or null mappings. + * + * @param function A mapping function, the results of which must implement Comparable + */ + public static > MinMaxGatherer minBy( + final Function function + ) { + return new MinMaxGatherer<>(false, function); + } + /** * Reverse the order of the input Stream. * diff --git a/src/main/java/com/ginsberg/gatherers4j/MinMaxGatherer.java b/src/main/java/com/ginsberg/gatherers4j/MinMaxGatherer.java new file mode 100644 index 0000000..2feab43 --- /dev/null +++ b/src/main/java/com/ginsberg/gatherers4j/MinMaxGatherer.java @@ -0,0 +1,75 @@ +/* + * Copyright 2024 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.function.BiConsumer; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Gatherer; + +public class MinMaxGatherer> + implements Gatherer, INPUT> { + + private final Function mappingFunction; + private final boolean max; + + MinMaxGatherer(final boolean max, final Function mappingFunction) { + this.mappingFunction = mappingFunction; + this.max = max; + } + + @Override + public Supplier> initializer() { + return State::new; + } + + @Override + public Integrator, INPUT, INPUT> integrator() { + return (state, element, downstream) -> { + final MAPPED mapped = element == null ? null : mappingFunction.apply(element); + if (mapped == null) { + return !downstream.isRejecting(); + } + if (state.bestSoFar == null) { + state.bestSoFar = element; + state.mappedField = mapped; + } else { + final int compared = mapped.compareTo(state.mappedField); + if ((compared > 0 && max) || (compared < 0 && !max)) { + state.bestSoFar = element; + state.mappedField = mapped; + } + } + return !downstream.isRejecting(); + }; + } + + @Override + public BiConsumer, Downstream> finisher() { + return (state, downstream) -> { + if (state.bestSoFar != null) { + downstream.push(state.bestSoFar); + } + // TODO: What happens if we have no best? + }; + } + + public static class State> { + private INPUT bestSoFar; + private MAPPED mappedField; + } +} diff --git a/src/test/java/com/ginsberg/gatherers4j/MinMaxGathererTest.java b/src/test/java/com/ginsberg/gatherers4j/MinMaxGathererTest.java new file mode 100644 index 0000000..3285fd0 --- /dev/null +++ b/src/test/java/com/ginsberg/gatherers4j/MinMaxGathererTest.java @@ -0,0 +1,154 @@ +/* + * Copyright 2024 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.List; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +class MinMaxGathererTest { + + private record TestObject(String a, int b) { + } + + @Nested + class Max { + + @Test + void doesNotTestNullMappings() { + // Arrange + final Stream input = Stream.of(new TestObject(null, 1)); + + // Act + final List output = input.gather(Gatherers4j.maxBy(TestObject::a)).toList(); + + // Assert + assertThat(output).isEmpty(); + } + + @Test + void emptyStreamHasNoBest() { + // Arrange + final Stream input = Stream.empty(); + + // Act + final List output = input.gather(Gatherers4j.maxBy(TestObject::b)).toList(); + + // Assert + assertThat(output).isEmpty(); + } + + @Test + void firstValueSelectedWhenMultipleExist() { + // Arrange + final Stream input = Stream.of( + new TestObject("A", 1), + new TestObject("B", 1) + ); + + // Act + final List output = input.gather(Gatherers4j.maxBy(TestObject::b)).toList(); + + // Assert + assertThat(output).containsExactly(new TestObject("A", 1)); + } + + @Test + void maxValue() { + // Arrange + final Stream input = Stream.of( + new TestObject("A", 1), + new TestObject("B", 2), + new TestObject("C", 3), + new TestObject("E", 2), + new TestObject("E", 1) + ); + + // Act + final List output = input.gather(Gatherers4j.maxBy(TestObject::b)).toList(); + + // Assert + assertThat(output).containsExactly(new TestObject("C", 3)); + } + } + + @Nested + class Min { + + @Test + void doesNotTestNullMappings() { + // Arrange + final Stream input = Stream.of(new TestObject(null, 1)); + + // Act + final List output = input.gather(Gatherers4j.minBy(TestObject::a)).toList(); + + // Assert + assertThat(output).isEmpty(); + } + + @Test + void emptyStreamHasNoBest() { + // Arrange + final Stream input = Stream.empty(); + + // Act + final List output = input.gather(Gatherers4j.minBy(TestObject::b)).toList(); + + // Assert + assertThat(output).isEmpty(); + } + + @Test + void firstValueSelectedWhenMultipleExist() { + // Arrange + final Stream input = Stream.of( + new TestObject("A", 1), + new TestObject("B", 1) + ); + + // Act + final List output = input.gather(Gatherers4j.minBy(TestObject::b)).toList(); + + // Assert + assertThat(output).containsExactly(new TestObject("A", 1)); + } + + @Test + void minValue() { + // Arrange + final Stream input = Stream.of( + new TestObject("A", 3), + new TestObject("B", 2), + new TestObject("C", 1), + new TestObject("E", 2), + new TestObject("E", 3) + ); + + // Act + final List output = input.gather(Gatherers4j.minBy(TestObject::b)).toList(); + + // Assert + assertThat(output).containsExactly(new TestObject("C", 1)); + } + } + +} \ No newline at end of file