Powered by AppSignal & Oban Pro

Lazy Product Filters

exercises/lazy_product_filters.livemd

Lazy 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

Return Home<span style=”padding: 0 30px”></span> Report An Issue

Lazy 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:

  1. a partial case-insensitive :name field.
  2. an inclusive :min and :max price.
  3. an exact :category field as an atom.
Example Test Cases ```elixir 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. ```elixir 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 ```elixir 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 ) ```

Mark As Completed

file_name = Path.basename(Regex.replace(~r/#.+/, __ENV__.file, ""), ".livemd")

save_name =
  case Path.basename(__DIR__) do
    "reading" -> "lazy_product_filters_reading"
    "exercises" -> "lazy_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 lazy-product-filters-exercise
$ git add .
$ git commit -m "finish lazy product filters exercise"
$ git push origin lazy-product-filters-exercise

Create a pull request from your lazy-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
Streams Lists and Tuples