4

I'm playing with java reflection and learning more about Stream.collect.

I have an annotation MyTag that has two properties (id and type enum[Normal|Failure]). Also, I have a list of annotated methods with MyTag and I was able to group those methods by the id property of the MyTag annotation using Collectors.groupingBy:

List<Method> ml = getMethodsAnnotatedWith(anClass.getClass(),
                                           MyTag.class);
Map<String, List<Method>> map = ml.stream().collect(groupingBy(m -> {
      var ann = m.getDeclaredAnnotation(MyTag.class);
      return ann.anId();
    }, TreeMap::new, toList()));

Now I need to reduce the resulting List to one single object composed of ONLY TWO items of the same MyTag.id, one with a MyTag.type=Normal and the other with a MyTag.type=Failure. So it would result in something like a Map<String, Pair<Method, Method>>. If there are more than two occurrences, I must just pick the first ones, log and ignore the rest.

How could I achieve that ?

Cristiano
  • 1,414
  • 15
  • 22

3 Answers3

6

You can use

Map<String, Map<Type, Method>> map = Arrays.stream(anClass.getClass().getMethods())
    .filter(m -> m.isAnnotationPresent(MyTag.class))
    .collect(groupingBy(m -> m.getDeclaredAnnotation(MyTag.class).anId(),
            TreeMap::new,
            toMap(m -> m.getDeclaredAnnotation(MyTag.class).aType(),
                  m -> m, (first, last) -> first,
                  () -> new EnumMap<>(Type.class))));

The result maps the annotations ID property to a Map from Type (the enum constants NORMAL and FAILURE) to the first encountered method with a matching annotation. Though “first” has not an actual meaning when iterating over the methods discovered by Reflection, as it doesn’t guaranty any specific order.

The () -> new EnumMap<>(Type.class) map factory is not necessary, it would also work with the general purpose map used by default when you don’t specify a factory. But the EnumMap will handle your case of having only two constants to map in a slightly more efficient way and its iteration order will match the declaration order of the enum constants.

I think, the EnumMap is better than a Pair<Method, Method> that requires to remember which method is associated with “normal” and which with “failure”. It’s also easier to adapt to more than two constants. Also, the EnumMap is built-in and doesn’t require a 3rd party library.

Holger
  • 285,553
  • 42
  • 434
  • 765
  • Thank you, Holger. very interesting and flexible approach. In my tests, I was able to do something like yours, but using an inner `groupingBy`. but it used the Enum text instead. I didn't know the `EnumMap`. If I instead would like to reduce to one single element, a customs collector would be the way to go? – Cristiano Mar 04 '21 at 19:18
  • 1
    @Cristiano do you mean “one single element” instead of the inner map? You can replace the outer `groupingBy` with `toMap` using a merge function if a plain Reduction of the values fits your task. Otherwise, `groupingBy` with a different collector, whether built-in or custom, would be the right thing. Compare with [this Q&A](https://stackoverflow.com/q/57041896/2711488). – Holger Mar 05 '21 at 07:14
2

The following example can easily be adapted to your code:

import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;
import java.util.stream.Collectors;

import org.apache.commons.lang3.tuple.Pair;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class Test {

    private static final Logger logger = LoggerFactory.getLogger(Test.class);

    public static void main(String[] args) {
        List<Pair<String, String>> ml = Arrays.asList(
                Pair.of("key1", "value1"),
                Pair.of("key1", "value1"),
                Pair.of("key1", "value2"),
                Pair.of("key2", "value1"),
                Pair.of("key2", "value3"));

        Map<String, Pair<String, String>> map = ml.stream().collect(
                Collectors.groupingBy(m -> {
                    return m.getKey();
                }, TreeMap::new, Collectors.toList()))
                .entrySet()
                .stream()
                .collect(Collectors.toMap(
                        Map.Entry::getKey, e -> convert(e.getValue())));

        System.out.println(map.values());
    }

    private static Pair<String, String> convert(List<Pair<String, String>> original) {
        long count1 = original.stream().filter(e -> Objects.equals(e.getValue(), "value1")).count();
        long count2 = original.stream().filter(e -> Objects.equals(e.getValue(), "value2")).count();
        if (count1 > 1) {
            logger.warn("More than one occurrence of value1");
        }
        if (count2 > 1) {
            logger.warn("More than one occurrence of value2");
        }
        return Pair.of(count1 > 0 ? "value1" : null,
                count2 > 0 ? "value2" : null);
    }

}
  • Instead of Pair<String, String> use Method
  • m.getDeclaredAnnotation(MyTag.class).anId() corresponds to pair.getKey()

The folowing result is printed to the console:

01:23:27.959 [main] WARN syglass.Test2 - More than one occurrence of value1
[(value1,value2), (value1,null)]
Mykhailo Skliar
  • 1,242
  • 1
  • 8
  • 19
1

First, create your own MethodPair class:

class MethodPair {
  private final Method failure;
  private final Method normal;

  public MethodPair(Method failure, Method normal) {
    this.failure = failure;
    this.normal = normal;
  }

  public Method getFailure() {
    return failure;
  }

  public Method getNormal() {
    return normal;
  }

  public MethodPair combinedWith(MethodPair other) {
    return new MethodPair(
        this.failure == null ? other.failure : this.failure,
        this.normal == null ? other.normal : this.normal)
    );
  }
}

Notice the combinedWith method. This is going to useful in the reduction that we are going to do.

Instead of toList, use the reducing collector:

Map<String, MethodPair> map = ml.stream().collect(groupingBy(m -> {
  var ann = m.getDeclaredAnnotation(MyTag.class);
  return ann.anId();
}, TreeMap::new,
    Collectors.reducing(new MethodPair(null, null), method -> {
      var type = method.getDeclaredAnnotation(MyTag.class).type();
      if (type == Type.NORMAL) {
        return new MethodPair(null, method);
      } else {
        return new MethodPair(method, null);
      }
    }, MethodPair::combinedWith)
    ));

If you are fine with doing this in two steps, I would suggest that you create the Map<String, List<Method>> first, then map its values to a new map. IMO this is more readable:

Map<String, List<Method>> map = ml.stream().collect(groupingBy(m -> {
  var ann = m.getDeclaredAnnotation(MyTag.class);
  return ann.anId();
}, TreeMap::new, toList()));
var result = map.entrySet().stream().collect(Collectors.toMap(entry -> entry.getKey(), entry -> {
  Method normal = null;
  Method failure = null;
  for (var m : entry.getValue()) {
    var type = m.getDeclaredAnnotation(MyTag.class).type();
    if (type == Type.NORMAL && normal == null) {
      normal = m;
    } else if (type == Type.FAILURE && failure == null) {
      failure = m;
    }
    if (normal != null && failure != null) {
      break;
    }
  }
  return new MethodPair(failure, normal);
}));
Sweeper
  • 213,210
  • 22
  • 193
  • 313
  • Very nice solution thank you. I'm testing it but there is a problem: Every time the combiner compares with the Identity, a NullPointerException is thrown due to the MethodPair::combineWith null checks. – Cristiano Mar 04 '21 at 19:12
  • @Cristiano Should be fixed now. – Sweeper Mar 05 '21 at 00:17
  • @Swepper, I was able to create a collector implementation to be used after `groupingBy` instead to use the `reducing`. This allowed me to just change the method value (when null) in my mutable accumulator class instead of creating a new object every time. It worked properly in parallel too. The answer from @Holger is undoubtedly the most flexible one for most situations, but I'm choosing yours because it drives me to learn what I needed. thanks. – Cristiano Mar 05 '21 at 04:32