Ovidiu G
Ovidiu G

Reputation: 1273

Applying multiple filters on an array

I have this project which has a search page where I have a basic filter form. I managed to filter the data but only with one filter at a time. I can't figure the logic behind applying multiple filters in order to narrow down the data.

Example:

let data = [{
    budget: "220",
    categories: ["party", "school"]
  },
  {
    budget: "450",
    categories: ["self"]
  },
  {
    budget: "600",
    categories: ["dev", "work"]
  }
];

const filters = {
  budget: ["200","500"],
  categories: ["party"]
}

//////Expected behavior:
Outputs only the first object because it's budget it's between 200 and 500 and it has "party" as it's category.

This example simulates basically what I have in my app. As you can see, I have an array of objects. Each object has a budget and multiple categories. For the filters, let's say I apply a budget filter (which is going to be a range filter) and one category filter. How should I chain this filters together in order to filter the data properly?

Upvotes: 2

Views: 2372

Answers (4)

user3297291
user3297291

Reputation: 23372

This question is a great excuse for a functional programming excercise :D

Array.prototype.filter takes a predicate function. A predicate takes one argument and returns a boolean. Although javascript won't complain, it makes sense that the types of the elements in the array match the types your predicate can handle.

One predicate

You already got to the point of filtering with one predicate, but I'll include an example anyway:

// [ number ]
const numbers = [ 1, 2, 3, 4, 5, 6 ];

// number -> bool
const lt5 = x => x < 5;

// [ number ] -> (number -> bool) -> [ number ]
const result = numbers.filter(lt5);

console.log(result); // [ 1, 2, 3, 4 ]

Two predicates

Now, let's say you only want the even numbers that are less than 5... How do we apply multiple filters? The most straightforward way is to filter twice:

// [ number ]
const numbers = [ 1, 2, 3, 4, 5, 6 ];

// number -> bool
const lt5 = x => x < 5;
// number -> bool
const even = x => x % 2 === 0;

const result = numbers
  .filter(lt5) // [ 1, 2, 3, 4 ]
  .filter(even);

console.log(result); // [ 2, 4 ]

Any or All predicates?

Although some people will complain about efficiency (this loops 10 times), I'd actually recommend this approach whenever you need the elements to pass all of multiple filters.

However, if we want to switch between being able to filter items that either all, or any of our predicates, we need another approach. Luckily, there's some and every!

// [ number ]
const numbers = [ 1, 2, 3, 4, 5, 6 ];

// number -> bool
const lt5 = x => x < 5;
// number -> bool
const even = x => x % 2 === 0;

// number -> bool
const lt5_OR_even = x => [lt5, even].some(f => f(x));

// number -> bool
const lt5_AND_even = x => [lt5, even].every(f => f(x));

console.log(
  numbers.filter(lt5_OR_even) // [ 1, 2, 3, 4, 6 ]
); 

console.log(
  numbers.filter(lt5_AND_even) // [ 2, 4 ]
); 

Composing predicates

Instead of looping over arrays of predicates, we can also take a different approach. We can compose our predicates in to new ones using two small helpers, both and either:

// (a -> bool) -> (a -> bool) -> a -> bool
const both = (f, g) => x => f(x) && g(x);

// (a -> bool) -> (a -> bool) -> a -> bool
const either = (f, g) => x => f(x) || g(x);

const numbers = [ 1, 2, 3, 4, 5, 6 ];

const lt5 = x => x < 5;
const even = x => x % 2 === 0;

console.log(
  numbers.filter(either(lt5, even)) // [ 1, 2, 3, 4, 6 ]
); 

console.log(
  numbers.filter(both(lt5, even)) // [ 2, 4 ]
);

With these helpers, we can take any array of predicates and merge them in to one! The only thing we need to add is a "seed" so we can reduce safely:

// (a -> bool) -> (a -> bool) -> a -> bool
const both = (f, g) => x => f(x) && g(x);

// (a -> bool) -> (a -> bool) -> a -> bool
const either = (f, g) => x => f(x) || g(x);

// any -> bool
const True = _ => true;

const Filter = (predicates, comparer = both) =>
  predicates.reduce(comparer, True);
  
  
const myPred = Filter([
  x => x > 5,
  x => x < 10,
  x => x % 2 === 0
]);


console.log(
  [1,2,3,4,5,6,7,8,9,10,11].filter(myPred) // [ 6, 8 ]
);

Back to your data!

Putting it all together, you start to realize this overcomplicates things for simple examples 😅. However, it's still interesting to see how we can reuse and combine single purpose, testable functions in a functional manner.

const both = (f, g) => x => f(x) && g(x);
const either = (f, g) => x => f(x) || g(x);
const True = _ => true;
const gte = min => x => +x >= +min;
const lte = max => x => +x <= +max;
const overlap = (xs, ys) => xs.some(x => ys.includes(x));

const Filter = (predicates, comparer = both) =>
  predicates.reduce(comparer, True);

const BudgetFilter = ([min, max]) => ({ budget }) =>
  Filter([ gte(min), lte(max) ]) (budget);
  
const CategoryFilter = allowed => ({ categories }) =>
  overlap(allowed, categories);

const EventFilter = (cfg, opts) => Filter(
  Object
    .entries(opts)
    .map(([k, v]) => cfg[k](v))
);

// App:
const filterConfig = {
  budget: BudgetFilter,
  categories: CategoryFilter
};

const cheapPartyFilter = EventFilter(
  filterConfig, 
  {
    budget: ["200", "500"],
    categories: ["party"]
  }
);

let data = [{ budget: "220", categories: ["party", "school"] }, { budget: "450", categories: ["self"] }, { budget: "600", categories: ["dev", "work", "party"] }];

console.log(data.filter(cheapPartyFilter));

Upvotes: 5

Ori Drori
Ori Drori

Reputation: 191916

Each filter should have a function that can handle the check. The filterHandlers is a Map of such handlers.

The array and the object of filters are passed to applyFilters(). The method gets the keys of the filters (via Object.keys()), and iterates the items with Array.filters(). Using Array.every() the item is checked by all filters (using the relevant handler). If all checks pass, the item will be included in the resulting array.

Whenever you need to add another filter, you add another handler to the Map.

const filterHandlers = new Map([
  [
    'budget', 
    (val, [min, max]) => val >= min && val <= max
  ],
  [
    'categories', 
    (current, categories) => current.some( // some - at least one, every - all of them 
      (c) => categories.includes(c)
    )
  ],
  []
]);

const applyFilters = (arr, filters) => {
  const filterKeys = Object.keys(filters);
  
  return arr.filter(o => filterKeys.every((key) => {
    const handler = filterHandlers.get(key);
    
    return !handler || handler(o[key], filters[key]);
  }));
}

const data = [{"budget":"220","categories":["party","school"]},{"budget":"450","categories":["self"]},{"budget":"600","categories":["dev","work"]}];

const filters = {"budget":["200","500"],"categories":["party"]};

const result = applyFilters(data, filters);

console.log(result);

Upvotes: 5

Shyam Babu
Shyam Babu

Reputation: 1079

As someone suggested above its a great functional programming excercise. So this is my take on it. Might be little bit trickier to understand And it is not pure FP per say, but you can modify and add as many filter to each as long as the data is an object is only one level deep.

function datafilter (datas, filters, filterops) {
 //the follwing two function convert the filters from [key, [op, operands]] to [key, filterFunction] based on your filters and filterops
 var getFilterFunctionFor = ([operation, operands])=> filterops[operation](operands)
 var filterFuncs = filters.map(([key, operation])=>[key, getFilterFunctionFor(operation)])

 //now filter the data by aplying each filterFunction to mathcing key in data 
 return datas.filter((data)=>{
    return filterFuncs.reduce((prevOpTrue, [key, applyFilterTo])=>(key in data) && prevOpTrue && applyFilterTo(data[key]), true)
 }) 
}

var datas = [{
    budget: "220",
    categories: ["party", "school"]
  },
  {
    budget: "450",
    categories: ["self"]
  },
  {
    budget: "600",
    categories: ["dev", "work"]
  }
];

var ops = {
    rangeBetween : ([min, max])=> (value)=>((value>= min)&&(value<=max)),
    contains : (key) => (list) => list.includes(key)
    }

var filters = [
    ['budget', ['rangeBetween', ["200", "500"]]],
    ['categories',['contains', 'party']]
]
console.log(datafilter(datas,filters, ops))

Upvotes: 0

Eddie
Eddie

Reputation: 26844

You can use filter() to filter the array. Use && for multiple conditions. Use .every() to check if all elements of filters.categories are on current entry.

let data = [{
    budget: "220",
    categories: ["party", "school"]
  },
  {
    budget: "450",
    categories: ["self"]
  },
  {
    budget: "600",
    categories: ["dev", "work"]
  }
];

const filters = {
  budget: ["200", "500"],
  categories: ["party"]
}

let result = data.filter(o => filters.budget[0] <= o.budget && filters.budget >= o.budget[1] && filters.categories.every(e => o.categories.includes(e)))

console.log(result);

Upvotes: 1

Related Questions