MapSet Product Filters
Mix.install([
{:jason, "~> 1.4"},
{:kino, "~> 0.8.0", override: true},
{:youtube, github: "brooklinjazz/youtube"},
{:hidden_cell, github: "brooklinjazz/hidden_cell"},
{:benchee, "~> 1.1"},
{:faker, "~> 0.17.0"}
])
Navigation
MapSet 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).
Instead of using a list of products, you’re going to re-implement the product search using a MapSet of products.
products =
MapSet.new([
%{name: "Laptop", category: :tech, price: 100_000},
%{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 and test cases using MapSets.
You should be able to filter by:
-
a partial case-insensitive
:namefield. -
an inclusive
:minand:maxprice. -
an exact
:categoryfield as an atom.
Example Test Cases
ExUnit.start(auto_run: false)
defmodule MapSetProductsTest do
use ExUnit.Case
test "filter/2 empty filters" do
products = MapSet.new([make_product()])
assert MapSetProducts.filter(products, []) == products
end
test "filter/2 by exact matching name" do
found = make_product(name: "AAA")
not_found = make_product(name: "B")
products = MapSet.new([found, not_found])
assert MapSetProducts.filter(products, name: "AAA") == MapSet.new([found])
end
test "filter/2 by partial matching name" do
found = make_product(name: "AAA")
not_found = make_product(name: "B")
products = MapSet.new([found, not_found])
assert MapSetProducts.filter(products, name: "A") == MapSet.new([found])
end
test "filter/2 by mixed case partial matching name" do
found = make_product(name: "AAA")
not_found = make_product(name: "B")
products = MapSet.new([found, not_found])
assert MapSetProducts.filter(products, name: "a") == MapSet.new([found])
end
test "filter/2 by category" do
found = make_product(category: :tech)
not_found = make_product(category: :health)
products = MapSet.new([found, not_found])
assert MapSetProducts.filter(products, category: :tech) == MapSet.new([found])
end
test "filter/2 by min price" do
found_above = make_product(price: 110)
found_exact = make_product(price: 100)
not_found = make_product(price: 90)
products = MapSet.new([found_above, found_exact, not_found])
assert MapSetProducts.filter(products, min: 100) == MapSet.new([found_above, found_exact])
end
test "filter/2 by max price" do
not_found = make_product(price: 110)
found_exact = make_product(price: 100)
found_below = make_product(price: 90)
products = MapSet.new([found_below, found_exact, not_found])
assert MapSetProducts.filter(products, max: 100) == MapSet.new([found_below, found_exact])
end
test "filter/2 by max and min price" do
not_found_above = make_product(price: 110)
found = make_product(price: 100)
not_found_below = make_product(price: 90)
products = MapSet.new([not_found_above, found, not_found_below])
assert MapSetProducts.filter(products, max: 100, min: 100) == MapSet.new([found])
end
test "filter/2 all filters" do
not_found_above = make_product(name: "AAA", category: :tech, price: 110)
not_found_below = make_product(name: "AAA", category: :tech, price: 90)
not_found_name = make_product(name: "BBB", category: :tech, price: 100)
not_found_category = make_product(name: "AAA", category: :health, price: 100)
found = make_product(name: "AAA", category: :tech, price: 100)
products =
MapSet.new([found, not_found_above, not_found_below, not_found_name, not_found_category])
assert MapSetProducts.filter(products, name: "a", category: :tech, max: 100, min: 100) ==
MapSet.new([found])
end
defp make_product(attrs \\ %{}) do
Enum.into(attrs, %{
name: Enum.random(["A", "B", "C"]),
category: Enum.random([:tech, :toys, :candy]),
price: Enum.random(1..100)
})
end
end
ExUnit.run()
Example Solution
In this example, we solve the problem by enumerating over products and checking each filter.
defmodule MapSetProducts do
def filter(products, filters \\ []) do
name_filter = filters[:name]
category_filter = filters[:category]
min_filter = filters[:min]
max_filter = filters[:max]
MapSet.filter(products, fn product ->
matches_name = !name_filter || Regex.match?(~r/#{name_filter}/i, product.name)
matches_category = !category_filter || product.category == category_filter
matches_min = !min_filter || min_filter <= product.price
matches_max = !max_filter || product.price <= max_filter
matches_name and matches_category and matches_min and matches_max
end)
end
end
Implement the MapSetProducts module using MapSet instead of the Enum module.
defmodule MapSetProducts do
@moduledoc """
Documentation for `Products`
"""
@doc """
Filter products by name, category, and price.
The name filter should be partial and case insensitive.
## Examples
No filters returns all products.
iex> MapSetProducts.filter(MapSet.new([%{name: "Laptop", category: :tech, price: 100}]), [])
MapSet.new([%{name: "Laptop", category: :tech, price: 100}])
Filter by name (case insensitive and partial matching)
iex> MapSetProducts.filter(MapSet.new([%{name: "Laptop", category: :tech, price: 100}]), name: "l")
MapSet.new([%{name: "Laptop", category: :tech, price: 100}])
Multiple Filters
iex> MapSetProducts.filter(MapSet.new([%{name: "Laptop", category: :tech, price: 100}]), min: 50, max: 200, name: "Laptop", category: :tech)
MapSet.new([%{name: "Laptop", category: :tech, price: 100}])
"""
def filter(products, filters \\ []) do
end
end
ExUnit.start(auto_run: false)
defmodule MapSetProductsTest do
use ExUnit.Case
test "filter/2 empty filters"
test "filter/2 by exact matching name"
test "filter/2 by partial matching name"
test "filter/2 by mixed case partial matching name"
test "filter/2 by category"
test "filter/2 by min price"
test "filter/2 by max price"
test "filter/2 by max and min price"
test "filter/2 all filters"
end
ExUnit.run()
Bonus: Benchmark
Did using MapSet 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: "1", category: :tech, min: 25, max: 50]
mapset_products = MapSet.new(products)
Benchee.run(
%{
"Enum" => fn -> EnumProducts.filter(products, filters) end,
"Stream" => fn -> StreamProducts.filter(mapset_products, filters) end
"MapSet" => fn -> MapSetProducts.filter(mapset_products, filters) end
},
memory_time: 2
)
You may create your solution below, or consider creating a product_filters mix project with all of your solutions and benchmarks as a GitHub project for your future resume!
Mark As Completed
file_name = Path.basename(Regex.replace(~r/#.+/, __ENV__.file, ""), ".livemd")
save_name =
case Path.basename(__DIR__) do
"reading" -> "mapset_product_filters_reading"
"exercises" -> "mapset_product_filters_exercise"
end
progress_path = __DIR__ <> "/../progress.json"
existing_progress = File.read!(progress_path) |> Jason.decode!()
default = Map.get(existing_progress, save_name, false)
form =
Kino.Control.form(
[
completed: input = Kino.Input.checkbox("Mark As Completed", default: default)
],
report_changes: true
)
Task.async(fn ->
for %{data: %{completed: completed}} <- Kino.Control.stream(form) do
File.write!(
progress_path,
Jason.encode!(Map.put(existing_progress, save_name, completed), pretty: true)
)
end
end)
form
Commit Your Progress
Run the following in your command line from the curriculum folder to track and save your progress in a Git commit.
Ensure that you do not already have undesired or unrelated changes by running git status or by checking the source control tab in Visual Studio Code.
$ git checkout -b mapset-product-filters-exercise
$ git add .
$ git commit -m "finish mapset product filters exercise"
$ git push origin mapset-product-filters-exercise
Create a pull request from your mapset-product-filters-exercise branch to your solutions branch.
Please do not create a pull request to the DockYard Academy repository as this will spam our PR tracker.
DockYard Academy Students Only:
Notify your teacher by including @BrooklinJazz in your PR description to get feedback.
You (or your teacher) may merge your PR into your solutions branch after review.
If you are interested in joining the next academy cohort, sign up here to receive more news when it is available.
Up Next
| Previous | Next |
|---|---|
| Drills: MapSets | Fibonacci Challenge |