You can't always hard-code filter requirements for a Java Stream. Sometimes, you'll need to create a runtime filter on the fly.

Take, for example, search criteria. You might not know ahead of time what fields a user wants to use for filtering. Some users might use just one field. Others might use two or three.

You gotta be ready for those situations.

And the good news is you can be ready for those situations with predicate chaining.

In this guide, I'll show you how to use it to build user-defined filters.

The CRM App

Let's say you're building a CRM app. It does the normal stuff that CRM apps do: tracks activities between sales reps and contacts.

Those activities get stored in a MongoDB database as documents. Each document persists info like the title of the activity as well as the type, outcome, location, start time, end time, notes, and the contact involved.

As it stands right now, if you retrieve all the documents from the activities collection, the resulting data set looks like the JSON dump at this link.

As you can see, we're not messing around here. You're going to be working with real-world data.

On the Java side, the Activity class with its related classes mimic the data set that you see above. You can see examples of those classes over on GitHub.

So you can just do a findAll() on that collection above and get a List of Java objects that represent that JSON output. Then, you can use a Java Stream to filter, find, and map as you see fit.

That's what you'll do in this guide.

The Task at Hand

I gave the game away in the lead. The requirement here is to generate return results from user-driven search criteria.

Let's say the user can search on contact last name, activity type, activity outcome, or any combination of those three criteria. 

You need to support. And, obviously, you can't hardcode the filter() method on the Stream when you're writing the code because you don't yet know which of the critieria the user will select.

You also don't know how many of the above criteria the user will select. It could be one, two, or all three.

So you've got your work cut out for you.

Fortunately, there's an answer to this dilemma. It's called predicate chaining.

The Chain Gang

Predicate chaining is exactly what it sounds like. It's a way to chain predicates together so they create one giant predicate.

You can think of predicate chaining as connecting individual predicates with an and or or. For the purposes of this guide, I'll focus on and because that's usually how multiple filters work in search criteria.

In pseudo code, the user could create search criteria that looks like this:

(contact last name == 'Cheng') and (activity outcome == 'Interested')

Or something like this:

(activity outcome == 'Interested') and (contact type == 'Appointment')

Fortunately, the Predicate interface actually includes the methods and() and or() so you can use them to connect (or "chain") Predicate instances together.

By the way, if you're unfamiliar with Predicates, they're functions that return a boolean. You can apply them to any kind of object you want.

It's often the case that developers implement Predicates with lambda expressions. You'll see that here in a moment.

But, But, But...

But you still don't know how many to chain. Fortunately, that's easy enough to fix as well.

Put 'em in a List.

Then, you can create a chain off of the Predicate objects in that List.

Basically, you'll connect each Predicate in the List with an "and."

Can it get more complicated than that to support both "and" and "or" conditionals? Yes it can.

Do I want to cover that right now? No I don't.

Predicatory Behavior

Now that you know what to do, make it happen.

Start by creating a method that returns a List of Predicate objects. Make it look like this:

private List<Predicate<Activity>> getPredicates(String contactLastName, String activityType, String outcome) {
    List<Predicate<Activity>> predicates = new ArrayList<>(); 
    
    if (!StringUtils.isBlank(contactLastName)) {
        Predicate<Activity> contactMatch = activity -> activity.getContact() != null && contactLastName.equals(activity.getContact().getLastName());
        predicates.add(contactMatch);
    }
    
    if (!StringUtils.isBlank(activityType)) {
        Predicate<Activity> activityTypeMatch = activity -> activity.getType() != null && activityType.equals(activity.getType().getName());
        predicates.add(activityTypeMatch);
    }
    
    if (!StringUtils.isBlank(outcome)) {
        Predicate<Activity> activityOutcomeMatch = activity -> activity.getOutcome() != null && outcome.equals(activity.getOutcome().getName());
        predicates.add(activityOutcomeMatch);
    }
    
    return predicates;
}

So that's pretty easy. And you can see that it follows a pattern.

The method accepts three (3) String arguments. Each one represents a different search criterion.

And, let's face it, the parameter names are self-explanatory.

The first line of the method instantiates an empty List of Predicate objects. So it starts with a clean slate.

Then the code checks each parameter that got passed in. If it's not blank (empty, null, or whitespace only), it uses that String to create a new Predicate instance.

For example, if contactLastName isn't blank, the code uses a lambda expression to create a Predicate instance. 

In that case, the code checks to see if the contact's last name matches the contactLastName parameter passed in to the method. If does match, the Predicate returns true. 

And then the code adds the newly created Predicate to the List.

You can see that the rest of the method follows that same pattern for the other two parameters before finally returning the List.

So at the end of that method, the code returns a List with 0, 1, 2, or 3 Predicate objects.

Non-Non-Binary

You're also going to need to create an instance of BinaryOperator.

Why? Well, you don't need to.

But it makes the code easier to read.

A BinaryOperator, by the way, is an extension of BiFunction. But whereas BiFunction can accept two arguments of any type and return a result of yet another type, BinaryOperator requires that both arguments and the return object are all of the same type.

Now what does all of this have to do with the price of dosa in Crabtree Valley Mall?

Here's the answer: you'll use the BinaryOperator to handle predicate chaining.

In other words, the BinaryOperator will accept two Predicate objects and return another Predicate object that connects those two via the and() method.

Here's the code that implements the BinaryOperator using a lambda expression:

BinaryOperator<Predicate<Activity>> conjunction = (p1, p2) -> p1.and(p2);

That's an implementation of the apply() method. Just like I said previously, it accepts two Predicate objects and returns a new one that joins those two with an and() method.

Streams Come True

Now it's time to put it all together and make something exciting happen.

Here's the code:

List<Activity> activities = activityRepo.findAll();

BinaryOperator<Predicate<Activity>> conjunction = (p1, p2) -> p1.and(p2);

List<Predicate<Activity>> predicates = getPredicates(null, "Chat", "Interested");

List<Activity> filteredActivities = activities
                                       .stream()
                                       .filter(predicates.stream().reduce(predicate -> true, conjunction))
                                       .collect(Collectors.toList());

try {
    ObjectMapper objectMapper = new ObjectMapper();
    System.err.println(objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(filteredActivities));
} catch (Exception e) {
    e.printStackTrace();
}

The first line grabs all activities from the MongoDB collection. The framework translates those documents into a List of Activity objects.

The next line I just explained in the previous section.

The third line uses the method I covered two sections ago. Here, it's requesting a List of Predicate objects that will ultimately join together to make a predicate chain.

Based on the parameters passed into that method, the code is only looking for activities with the type name of "Chat" that resulted in an "Interested" outcome.

Next, the code translates the List into a Stream. It does that with the aid of the stream() method you see above.

Then it performs the filter. And that's where things get interesting.

First, the code translates the List of Predicate objects into a Stream. So it's a Stream within a Stream!

Yes, that's perfectly legal.

Keep in mind, though: that second Stream includes only Predicate objects. The first Stream includes only Activity objects.

But back to the second stream. The code invokes the reduce() method to transform that sequence of Predicate objects into one uber-object. It takes x Predicates and makes them one Predicate by joining them together with and().

And how does it do that? With the aid of that BinaryOperator you wrote earlier. With an assist by the reduce() method.

The reduce() method accepts two parameters: an identity and an accumulator.

I'll probably explain those concepts in more detail in another blog post. But for now, just think of identity as the default value (true here) and the accumulator as an assembly line that puts all the Predicate objects together.

So here's how it breaks down:

  • identity: predicate -> true
  • accumulator: conjunction

So at the end of the day, the reduce() method returns a single Predicate.  Only Activity objects that match the filter criteria in that Predicate will be returned by the filter() method.

And then, finally, the resulting list of activities get returned as a List object thanks to Collectors.toList().

Does It Work?

Sure. Run it.

Take a look at those last few lines of code and you'll see that it translates the returned List of Activity objects into JSON and prints out the results in pretty red letters.

And if you do run it with the dataset I referenced earlier, you'll get this result:

[ {
  "id" : "601fdafc8fc6df7eddcf3ae2",
  "type" : {
    "id" : "6016a960ca8c08019b4dfcbd",
    "name" : "Chat",
    "icon" : "chat"
  },
  "title" : "Chatted with her via Skype",
  "outcome" : {
    "id" : "6016a408ae0e817a115fd06a",
    "name" : "Interested"
  },
  "notes" : "Talked about our history of delivering outstanding Java Enterprise software.",
  "location" : null,
  "startDate" : 1610367300000,
  "endDate" : null,
  "contact" : {
    "id" : "6014199147692f2a4194ff9a",
    "firstName" : "Yeezu",
    "lastName" : "Joy",
    "account" : {
      "id" : "60141483b56f731fe77e9031",
      "name" : "Empire"
    },
    "salesOwner" : {
      "id" : "6014081e221e1b534a8aa432",
      "firstName" : "Milton",
      "lastName" : "Jones",
      "username" : "milton"
    }
  }
}, {
  "id" : "601fe2488fc6df7eddcf3aeb",
  "type" : {
    "id" : "6016a960ca8c08019b4dfcbd",
    "name" : "Chat",
    "icon" : "chat"
  },
  "title" : "Another Skype chat",
  "outcome" : {
    "id" : "6016a408ae0e817a115fd06a",
    "name" : "Interested"
  },
  "notes" : "Seems interested in our Angular services based on the work we did for Rover. Let's hear it for good references.",
  "location" : null,
  "startDate" : 1610714700000,
  "endDate" : null,
  "contact" : {
    "id" : "6014199147692f2a4194ff9b",
    "firstName" : "Frum",
    "lastName" : "Lezilia",
    "account" : {
      "id" : "60141483b56f731fe77e9034",
      "name" : "International Business"
    },
    "salesOwner" : {
      "id" : "6014081e221e1b534a8aa432",
      "firstName" : "Milton",
      "lastName" : "Jones",
      "username" : "milton"
    }
  }
} ]

That's an array of two (2) objects. And they both match the criteria: they're "Chat" activities with an "Interested" outcome.

Try Again

Now give it some different criteria. How about this:

List<Predicate<Activity>> predicates = getPredicates("Cheng", null, null);

That will return all contacts with the last name of "Cheng."

Go ahead and run it. You'll get this:

[ {
  "id" : "601f2a162a913d1b4e94fef9",
  "type" : {
    "id" : "6016a960ca8c08019b4dfcbe",
    "name" : "Appointment",
    "icon" : "calendar_today"
  },
  "title" : "Lunch",
  "outcome" : null,
  "notes" : null,
  "location" : "Bruno's",
  "startDate" : 1612891800000,
  "endDate" : 1612895400000,
  "contact" : {
    "id" : "6014199147692f2a4194ff95",
    "firstName" : "Lucy",
    "lastName" : "Cheng",
    "account" : {
      "id" : "60141483b56f731fe77e902e",
      "name" : "Queen Inc."
    },
    "salesOwner" : {
      "id" : "6014081e221e1b534a8aa432",
      "firstName" : "Milton",
      "lastName" : "Jones",
      "username" : "milton"
    }
  }
}, {
  "id" : "601fd4dcda249c7531fd28f0",
  "type" : {
    "id" : "6016a960ca8c08019b4dfcbf",
    "name" : "Text Message",
    "icon" : "textsms"
  },
  "title" : "Texted me about an opening",
  "outcome" : null,
  "notes" : "She said she might have an opening in the next 6 months.",
  "location" : null,
  "startDate" : 1610719200000,
  "endDate" : null,
  "contact" : {
    "id" : "6014199147692f2a4194ff95",
    "firstName" : "Lucy",
    "lastName" : "Cheng",
    "account" : {
      "id" : "60141483b56f731fe77e902e",
      "name" : "Queen Inc."
    },
    "salesOwner" : {
      "id" : "6014081e221e1b534a8aa432",
      "firstName" : "Milton",
      "lastName" : "Jones",
      "username" : "milton"
    }
  }
}, {
  "id" : "601fd63fda249c7531fd28f1",
  "type" : {
    "id" : "6016a960ca8c08019b4dfcc0",
    "name" : "Web Form Completion",
    "icon" : "list_alt"
  },
  "title" : "Completed more info form on careydevelopment.us",
  "outcome" : null,
  "notes" : null,
  "location" : null,
  "startDate" : 1610452800000,
  "endDate" : null,
  "contact" : {
    "id" : "6014199147692f2a4194ff95",
    "firstName" : "Lucy",
    "lastName" : "Cheng",
    "account" : {
      "id" : "60141483b56f731fe77e902e",
      "name" : "Queen Inc."
    },
    "salesOwner" : {
      "id" : "6014081e221e1b534a8aa432",
      "firstName" : "Milton",
      "lastName" : "Jones",
      "username" : "milton"
    }
  }
}, {
  "id" : "601fd6b0da249c7531fd28f2",
  "type" : {
    "id" : "6016a960ca8c08019b4dfcc1",
    "name" : "Web Page Visitied",
    "icon" : "web"
  },
  "title" : "Visited page about Full Stack services",
  "outcome" : null,
  "notes" : null,
  "location" : null,
  "startDate" : 1610553600000,
  "endDate" : null,
  "contact" : {
    "id" : "6014199147692f2a4194ff95",
    "firstName" : "Lucy",
    "lastName" : "Cheng",
    "account" : {
      "id" : "60141483b56f731fe77e902e",
      "name" : "Queen Inc."
    },
    "salesOwner" : {
      "id" : "6014081e221e1b534a8aa432",
      "firstName" : "Milton",
      "lastName" : "Jones",
      "username" : "milton"
    }
  }
} ]

Boom. Nailed it.

Wrapping It Up

Homework assignment: what happens if you pass all nulls into the getPredicates() method? Try it and find out.

And ask yourself why it does that. Then answer your question.

Also, look for ways you can use predicate chaining to fulfill your current requirements.

Just make sure you have fun!

Photo by Travis Saylor from Pexels