Can I do this declaratively using groupingBy?

Viewed 81

I have an Employee class:

public class Employee {

    public enum Sex {
        MALE, FEMALE
    }

    private final String name;
    private final Sex gender;
    private final LocalDate dateOfBirth;
    private final double salary;
    private final List<String> roles;

    // Constructor
    public Employee(String name, Sex gender, LocalDate dateOfBirth, double salary, List<String> roles) {
        this.name = name;
        this.gender = gender;
        this.dateOfBirth = dateOfBirth;
        this.salary = salary;
        this.roles = roles;
    }

    // Getters & toString omitted

    public static List<Employee> listOfStaff() {

        List<Employee> staff = new ArrayList<>();
        staff.add(new Employee("Jack", Employee.Sex.MALE,
                IsoChronology.INSTANCE.date(1954, 12, 2), 49999.00,
                new ArrayList<>(Arrays.asList("Manager", "Director"))));
        staff.add(new Employee("Jill", Employee.Sex.FEMALE,
                IsoChronology.INSTANCE.date(1995, 10, 25), 24999.00,
                new ArrayList<>(Arrays.asList("Secretary", "Manager", "Personnel"))));
        staff.add(new Employee("Dorothy", Employee.Sex.FEMALE,
                IsoChronology.INSTANCE.date(1972, 4, 7), 21999.00,
                new ArrayList<>(Arrays.asList("Secretary", "Receptionist"))));
        staff.add(new Employee("Bert", Employee.Sex.MALE,
                IsoChronology.INSTANCE.date(1968, 11, 5), 21999.00,
                new ArrayList<>(Arrays.asList("Clerk", "Receptionist"))));
        staff.add(new Employee("Mary", Employee.Sex.FEMALE,
                IsoChronology.INSTANCE.date(2001, 12, 3), 16999.00,
                new ArrayList<>(Arrays.asList("Trainee", "Receptionist"))));
        staff.add(new Employee("Matthew", Employee.Sex.MALE,
                IsoChronology.INSTANCE.date(1962, 4, 7), 12999.00,
                new ArrayList<>(Arrays.asList("Personnel", "Receptionist"))));

        return staff;
    }
}

I can extract a role and people within that role iteratively:

public class TestGrouping {
      /* Grouping streams */
      public static void main(String[] args) {

          Map<String, List<String>> roleAndNames = new HashMap<>();

          for (Employee e : Employee.listOfStaff()) {
              List<String> roles = e.getRoles();
              List<String> names = new ArrayList<>();
              names.add(e.getName());

              for (String r : roles) {
                  if(roleAndNames.get(r) == null) {
                      roleAndNames.put(r, names);
                  } else {
                      roleAndNames.get(r).add(e.getName());
                  }
              }
          }
          for (Map.Entry<String, List<String>> entry : roleAndNames.entrySet()) {
              System.out.println(entry.getKey() + ":" + entry.getValue().toString());
          }
      }
}

Which gives me the following output:

enter image description here

I have tried doing the same thing declaratively using streams but can't seem to get the same results.

Thought I might be able to do this using groupingBy() and collectingAndThen() but no joy!

Is this possible?

2 Answers

Here is the solution step-by-step:

  1. You can use flatMap to get all the "role-name" possible pairs (Map.Entry<String, String>).
  2. Collect using Collectors.groupingBy into the Map structure using an additional Collectors.mapping downstream collector to extract the name of the flatmapped "role-name" pairs. It needs another one to packthese names into a List.
Map<String, List<String>> map = Employee.listOfStaff().stream()
        .flatMap(employee -> employee.getRoles()
                .stream()
                .map(role -> new AbstractMap.SimpleEntry<>(role, employee.getName())))
        .collect(Collectors.groupingBy(
                Entry::getKey,
                Collectors.mapping(Entry::getValue, Collectors.toList())));

map.forEach((k,v) -> System.out.println(k + ":" + v));

I see you use . If you later switch to a newer Java, you can use Map.entry(..) as of Java 9 instead of new AbstractMap.SimpleEntry(...).


Edit: If you don't want to use , to fix your current iterative code, you need to either fix List insertion/update to the Map (both snippets work the same):

Map<String, List<String>> roleAndNames = new HashMap<>();
for (Employee employee: Employee.listOfStaff()) {
    for (String role: employee.getRoles()) {
        List<String> names = new ArrayList<>();
        if (roleAndNames.containsKey(role)) {
            names = roleAndNames.get(role);
        }
        names.add(employee.getName());
        roleAndNames.put(role, names);
    }
}
Map<String, List<String>> roleAndNames = new HashMap<>();
for (Employee employee: Employee.listOfStaff()) {
    for (String role: employee.getRoles()) {
        List<String> names = roleAndNames.getOrDefault(role, new ArrayList<>());
        names.add(employee.getName());
        roleAndNames.put(role, names);
    }
}

... or better use Map#computeIfAbsent which creates a new entry if there is not any AND always returns the value (which is a List, either an empty one OR with some names). So, you always use it to add the name in it with each iteration.

Map<String, List<String>> roleAndNames = new HashMap<>();
for (Employee employee: Employee.listOfStaff()) {
    for (String role: employee.getRoles()) {
        roleAndNames.computeIfAbsent(role, roleKey -> new ArrayList<>())
                    .add(employee.getName());
    }
}

That's all :) I love this method and its return type. It is very handy!

The trick is to first 'explode' your input into the cross product: You want 1 entry in your stream for each person/role combination. Once you have that, you can collapse this back into a map keyed on Role by using the groupingBy operation. Think databases and SQL if you're familiar with that.

Employee.listOfStaff().stream()
   .flatMap(employee -> employee.getRoles().stream());

This gets you the crosscut (flatMap 'maps' 1 entry in a stream into 0-x entries, by providing it a new stream. All the streams produced by flatMap are concatenated into a single new stream which is what you're operating on when you write .foo() after the flatMap operation.

From here you can do a fairly basic Collectors.groupingBy to get at the desired result.

Just FYI, this:

public enum Sex {
   MALE, FEMALE
}

is not a good idea. It's 2021, not 1970. Don't ask for sex unless there is no other way. For example, if you're gathering this because you want that info to be available so you can put 'Mr' or 'Mrs' at the top of letters, then get rid of the enum and the field and instead have a 'honorific'. Make an enum if you prefer. I'd just go with a String. Various government entities flat out can't use your software, or will at least be quite resistant to it. They have directives to adhere to that say to stop asking when it isn't needed, and it is rarely needed.

Related