Lazy Product Filters
Mix.install([
{:jason, "~> 1.4"},
{:kino, "~> 0.9", override: true},
{:youtube, github: "brooklinjazz/youtube"},
{:hidden_cell, github: "brooklinjazz/hidden_cell"},
{:benchee, "~> 1.1"},
{:faker, "~> 0.17.0"}
])
Navigation
Home Report An Issue Stream DrillsState: Agent And ETSLazy Product Filters
Previously in the Product Filters exercise, you built an application where users search for products based on certain filters.
Each product is a map with a :name
, :category
, and :price
(in cents).
products = [
%{name: "Laptop", category: :tech, price: 100000},
%{name: "Phone", category: :tech, price: 50000},
%{name: "Chocolate", category: :snacks, price: 200},
%{name: "Shampoo", category: :health, price: 1000}
]
You’re going to refactor and re-implement your existing Products.filter/2
function using Streams.
Ensure the refactored version passes your existing test suite. You should be able to filter by:
-
a partial case-insensitive
:name
field. -
an inclusive
:min
and:max
price. -
an exact
:category
field as an atom.
Example Test Cases
ExUnit.start(auto_run: false)
defmodule StreamProductsTest do
use ExUnit.Case
test "filter/2 empty filters" do
found = create_product(name: "Laptop")
assert StreamProducts.filter([found], []) == [found]
end
test "filter/2 by exact matching name" do
found = create_product(name: "Laptop")
not_found = create_product(name: "Shampoo")
products = [found, not_found]
assert StreamProducts.filter(products, name: "Laptop") == [found]
end
test "filter/2 by partial matching name" do
found = create_product(name: "Laptop")
not_found = create_product(name: "Shampoo")
products = [found, not_found]
assert StreamProducts.filter(products, name: "apt") == [found]
end
test "filter/2 by mixed case partial matching name" do
found = create_product(name: "Laptop")
not_found = create_product(name: "Shampoo")
products = [found, not_found]
assert StreamProducts.filter(products, name: "aPt") == [found]
end
test "filter/2 by category" do
found = create_product(category: :tech)
not_found = create_product(category: :snacks)
products = [found, not_found]
assert StreamProducts.filter(products, category: :tech) == [found]
end
test "filter/2 by min price" do
found1 = create_product(price: 101)
found2 = create_product(price: 100)
not_found = create_product(price: 99)
products = [found1, found2, not_found]
assert StreamProducts.filter(products, min: 100) == [found1, found2]
end
test "filter/2 by max price" do
found1 = create_product(price: 99)
found2 = create_product(price: 100)
not_found = create_product(price: 101)
products = [found1, found2, not_found]
assert StreamProducts.filter(products, max: 100) == [found1, found2]
end
test "filter/2 by max and min price" do
found1 = create_product(price: 100)
found2 = create_product(price: 150)
found3 = create_product(price: 200)
not_found1 = create_product(price: 99)
not_found2 = create_product(price: 201)
products = [found1, found2, found3, not_found1, not_found2]
assert StreamProducts.filter(products, min: 100, max: 200) == [found1, found2, found3]
end
test "filter/2 all filters" do
found = create_product(price: 150, name: "Laptop", category: :tech)
wrong_category = create_product(price: 150, name: "Laptop", category: :wrong)
wrong_name = create_product(price: 150, name: "Wrong", category: :wrong)
too_low_price = create_product(price: 99, name: "Laptop", category: :wrong)
too_high_price = create_product(price: 201, name: "Laptop", category: :wrong)
products = [found, wrong_category, wrong_name, too_low_price, too_high_price]
assert StreamProducts.filter(products, min: 100, max: 200, name: "Laptop", category: :tech) == [
found
]
end
# simplifies creation of product test data
defp create_product(attrs \\ %{}) do
attrs
|> Enum.into(%{
name: Enum.random(["Laptop", "Shampoo", "Phone"]),
category: Enum.random([:tech, :snacks, :health]),
price: Enum.random(1..1000)
})
end
end
ExUnit.run()
Example Solution
In this example, we solve the problem by enumerating over products and checking each filter.
defmodule StreamProducts do
def filter(products, filters) do
name_filter = filters[:name] || ""
min_filter = filters[:min]
max_filter = filters[:max]
category_filter = filters[:category]
products
|> Stream.filter(fn %{name: name} -> Regex.match?(~r/#{name_filter}/i, name) end)
|> Stream.reject(fn %{price: price} -> min_filter && price < min_filter end)
|> Stream.reject(fn %{price: price} -> max_filter && max_filter < price end)
|> Stream.reject(fn %{category: category} ->
category_filter && category_filter != category
end)
|> Enum.to_list()
end
end
Implement the StreamProducts
module using Streams instead of the Enum module.
defmodule StreamProducts do
@moduledoc """
Documentation for `Products`
"""
@doc """
Filter products by name, category, and price.
The name filter should be case insensitive and handle partial matches.
## Examples
No filters returns all products.
iex> StreamProducts.filter([%{name: "Laptop", category: :tech, price: 100}], [])
[%{name: "Laptop", category: :tech, price: 100}]
Filter by name
iex> StreamProducts.filter([%{name: "Laptop", category: :tech, price: 100}], name: "Laptop")
[%{name: "Laptop", category: :tech, price: 100}]
iex> StreamProducts.filter([%{name: "Laptop", category: :tech, price: 100}], name: "apt")
[%{name: "Laptop", category: :tech, price: 100}]
iex> StreamProducts.filter([%{name: "Laptop", category: :tech, price: 100}], name: "APT")
[%{name: "Laptop", category: :tech, price: 100}]
iex> StreamProducts.filter([%{name: "Laptop", category: :tech, price: 100}], name: "Phone")
[]
Multiple filters.
iex> StreamProducts.filter([%{name: "Laptop", category: :tech, price: 100}], min: 50, max: 200, name: "Laptop", category: :tech)
[%{name: "Laptop", category: :tech, price: 100}]
"""
def filter(products, filters) do
end
end
Bonus: Benchmark
Did using Stream improve the performance of your solution? Use Benchee to find out. Ensure you benchmark your solution with a large and varied data set. We’ve included the Faker project to make this easier.
Faker.Food.dish()
Consider adding the :memory_time
option to your benchmark to see which solution is more memory efficient.
Example Solution
names = Enum.map(1..1000, fn _ -> Faker.Food.dish() end)
categories = [:a, :b, :c, :d, :e, :f, :g, :h, :i, :j, :k, :l, :m, :n, :o, :p]
products =
for name <- names,
category <- categories,
do: %{name: name, category: category, price: Enum.random(1..100)}
filters = [name: "A", category: :tech, min: 25, max: 50]
Benchee.run(
%{
"Enum" => fn -> EnumProducts.filter(products, filters) end,
"Stream" => fn -> StreamProducts.filter(products, filters) end
},
memory_time: 2
)
Commit Your Progress
DockYard Academy now recommends you use the latest Release rather than forking or cloning our repository.
Run git status
to ensure there are no undesirable changes.
Then run the following in your command line from the curriculum
folder to commit your progress.
$ git add .
$ git commit -m "finish Lazy Product Filters exercise"
$ git push
We’re proud to offer our open-source curriculum free of charge for anyone to learn from at their own pace.
We also offer a paid course where you can learn from an instructor alongside a cohort of your peers. We will accept applications for the June-August 2023 cohort soon.