A few months back we added a new feature to the heart of our security ratings portal: the ability for users to not only filter companies in their portfolios, but also to see real-time updated counts of how many "filtered" companies match their selected filter criteria. In practice, this allows users to quickly see, for example, all of their vendors in the Technology or Finance industry with an IP footprint in the U.K or Germany that use Amazon or Google as service providers.
Getting these filter-counts is best done as a two step process. First, you filter; then, you count. And as the title of this post may have given away: Filtering is easy; Counting (on a single thread where you're also worrying about rendering a web page) is a lot harder, particularly from a performance perspective. After getting our basic filter-count algorithm in place, we ultimately had to delegate the counting portion to Web Workers to ensure that our page remained performant. This allowed us to achieve a balance of code complexity and performance in which users could filter their portfolios and the page remained interactive while the counts were updated.
We experimented with counting elements in the collection while concurrently filtering it, but realized that, while feasible, that process would add to the overall complexity of our code. Filtering is a relatively simple process that has a time complexity of O(n), while the counting step ended up being significantly greater as the number of possible values for individual fields within a given category grew. By splitting the process into two parts, we could isolate the more complex counting step from the filtering step, which allowed us to immediately update the UI when a filter option was selected and then lazily update the counts.
Filtering is Easy...
We explored several different approaches to the filtering part of our problem; initially we simply used the lodash filter function, which seemed to work to a certain extent. We quickly discovered, however, that this approach wasn't optimal for our use case as we didn't have as much control over the filtering process as we wanted. As a result, we created our own filter utility. Here's how it works:
- Start with a collection of objects that all share the same basic structure.
- Pass in an array of filterConfig objects that describe how we want our filter function to handle each key within the objects.
- Compose a predicate function by creating individual predicate functions for each field within the object based on three arguments: the field type of the base object (Array, String, or Number), a filterObject that serves as the archetype for the type of object we're searching for, and our filterConfig array. We get the type of the field by inspecting the first object in the array; if it’s an array, we check the type of the first element in the array and keep doing so until we find either a number or string.
- Call Array#filter on the collection of objects that we pass in to the function in (1) and let Javascript take care of the rest.
For example, say that we're filtering a collection of companies in Bitsight's portfolio:
companies
const companies = [
{ name: ‘Bitsight’, industry: 'Technology', country: ['US', 'UK', 'DE'], provider: ['AWS'] },
{ name: ‘Saperix’, industry: 'Finance', country: ['US', 'DE'], provider: ['AWS', 'Google', 'MS'] },
{ name: ‘Acme Education’, industry: 'Education', country: ['UK', 'FR'], provider: ['MS', 'Google'] },
{ name: ‘Goliath Mfg.’, industry: 'Manufacturing', country: ['DE', 'US', 'UK'], provider: ['AWS'] },
];
Our filterConfig object would look something like:
filterConfig
const filterConfig = [
{ name: 'Industry', key: 'industry', hidden: false },
{ name: 'Country', key: 'country', hidden: false },
{ name: 'Service Provider', key: 'provider', hidden: false},
];
As an astute reader you'll likely notice that we really only care about the key field in each filterConfig entry -- the others are utilized by our render function when we're showing these to the end user.
Once we have our collection of objects and a filterConfig we only need to generate a filterObject, which is an archetype of the kind of object that we're interested in finding. On Bitsight's portfolio page, this was accomplished via a User Interface (UI) but it could just as easily have been done via query parameters from a URL or some other method. In our example the filter object would look like:
filterObject
const filterObject = {
industry: ['Technology', 'Education'],
country: ['DE', 'UK'],
provider: ['AWS', 'Google'],
};
In plain English, the user wants companies in either the Technology or Education industries with IP footprints in Germany or the United Kingdom that use AWS or Google as service providers. To translate that into something that the browser can use (and to run the filter in a reasonable amount of time) we generate a predicate for each member of the filterObject, and then compose all of those individual predicates into one larger predicate that we can then pass each item in our collection through; if each individual predicate is true then the composed predicate is true and the item passes through the filter. So, in the case of our companies collection, we find ourselves left with:
filteredCompanies
const filteredCompanies = [
{ industry: 'Technology', country: ['US', 'UK', 'DE'], provider: ['AWS'] },
{ industry: 'Education', country: ['UK', 'FR'], provider: ['MS', 'Google'] },
];
...Counting is Hard
The filtering described above was relatively simple to implement; getting our filter counts in a performant manner proved to be more difficult. Our initial, naive solution for getting counts was to simply iterate through each key on each object and count the number of times a given entry appears under a given key:
counter-naive.js
const count = (items) => {
const result = {};
items.forEach((item) => { // iterate through all our items
for (const key in item) { // iterate through all the keys in each item
if (!Array.isArray(item[key])) {
item[key] = [ item[key] ]; // cast any strings as arrays
}
item[key].forEach((singleFilterValue) => {
result[key][singleFilterValue] = (result[key][singleFilterValue]
? result[key][singleFilterValue] + 1
: 1);
});
}
}
});
return result;
};
The problem with this approach was that it was slow, and since all of the counts were being computed within our customers' browsers, slowness here had the unwanted side effect of blocking the rendering and interactivity of our portfolio page. The first optimization we made was to rewrite our count function to only count over a single category at a time. (As the user is only clicking on one filter option at a time, we only needed to re-calculate our counts for a single category.) We also cast everything as an array when we initially got the data from the server which allowed us to get rid of the explicit cast.