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 super INPUT>> 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