Skip to content

Commit

Permalink
feat: Filtering for in-memory Alerts cache
Browse files Browse the repository at this point in the history
Supports filtering the Alerts in the in-memory cache on route,
route_type, direction_id, stop, and activity.

Route, stop, route type, and activity filters are lists of values where
_any_ of these values need to be present in at least one of the informed
entities of an alert to match.

If no activity filters are provided the default activity filter of
BOARD, EXIT, or RIDE is applied.

Direction ID can only be filtered on one value at a time because filtering
on multiple values is the same as removing the filter altogether.

If no filters are passed no filtering is done including no filtering on
the default activity filter.
  • Loading branch information
sloanelybutsurely committed Aug 2, 2024
1 parent 7d020b5 commit 88bc38d
Show file tree
Hide file tree
Showing 4 changed files with 273 additions and 0 deletions.
37 changes: 37 additions & 0 deletions lib/screens/alerts/alert.ex
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
defmodule Screens.Alerts.Alert do
@moduledoc false

import Screens.RouteType, only: :macros

alias Screens.Alerts.Cache
alias Screens.Alerts.InformedEntity
alias Screens.Routes.Route
alias Screens.RouteType
Expand Down Expand Up @@ -189,6 +192,40 @@ defmodule Screens.Alerts.Alert do
end)
end

def fetch_from_cache(filters \\ [], get_all_alerts \\ &Cache.all/0) do
alerts = get_all_alerts.()

filters =
filters
|> Enum.map(&format_cache_filter/1)
|> Enum.reject(&is_nil/1)
|> Enum.into(%{})

Screens.Alerts.Cache.Filter.filter_by(alerts, filters)
end

defp format_cache_filter({:route_id, route_id}), do: {:routes, [route_id]}
defp format_cache_filter({:stop_id, stop_id}), do: {:stops, [stop_id]}
defp format_cache_filter({:route_ids, route_ids}), do: {:routes, route_ids}
defp format_cache_filter({:stop_ids, stop_ids}), do: {:stops, stop_ids}

defp format_cache_filter({:route_type, route_type}),
do: format_cache_filter({:route_types, [route_type]})

defp format_cache_filter({:route_types, route_types}) do
route_types =
Enum.map(route_types, fn
route_type when is_route_type(route_type) -> RouteType.to_id(route_type)
route_type -> route_type
end)

{:route_types, route_types}
end

defp format_cache_filter({:direction_id, :both}), do: nil

defp format_cache_filter(filter), do: filter

@doc """
Convenience for cases when it's safe to treat an API alert data outage
as if there simply aren't any alerts for the given parameters.
Expand Down
108 changes: 108 additions & 0 deletions lib/screens/alerts/cache/filter.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
defmodule Screens.Alerts.Cache.Filter do
@moduledoc """
Logic to apply filters to a list of `Screens.Alerts.Alert` structs.
"""
@default_activities ~w[BOARD EXIT RIDE]

@type filter_opts() :: %{
optional(:routes) => [String.t()],
optional(:route_types) => [0..4 | nil],
optional(:direction_id) => 0 | 1,
optional(:stops) => [String.t()],
optional(:activities) => [String.t()]
}

@spec filter_by([Screens.Alerts.Alert.t()], filter_opts()) :: [Screens.Alerts.Alert.t()]
def filter_by(alerts, filter_opts) when filter_opts == %{}, do: alerts

def filter_by(alerts, filter_opts) do
filter_opts = Map.put_new(filter_opts, :activities, @default_activities)

alerts
|> filter(filter_opts)
|> filter_by_informed_entity_activity(filter_opts)
end

defp filter(alerts, filter_opts) do
filter_opts
|> build_matchers()
|> apply_matchers(alerts)
end

defp filter_by_informed_entity_activity(alerts, %{activities: values}) do
values = MapSet.new(values)

if MapSet.member?(values, "ALL") do
alerts
else
alerts
|> Enum.filter(fn alert ->
Enum.any?(alert.informed_entities, fn informed_entity ->
activities =
informed_entity
|> Map.get(:activities, [])
|> MapSet.new()

not MapSet.disjoint?(activities, values)
end)
end)
end
end

defp filter_by_informed_entity_activity(alerts, filter_opts) do
filter_opts = Map.put(filter_opts, :activities, @default_activities)

filter_by_informed_entity_activity(alerts, filter_opts)
end

defp build_matchers(filter_opts) do
filter_opts
|> Enum.reduce([%{}], &build_matcher/2)
end

defp apply_matchers(matchers, alerts) do
alerts
|> Enum.filter(&matches?(&1, matchers))
end

defp build_matcher({:routes, values}, acc) when is_list(values) do
matchers_for_values(acc, :route, values)
end

defp build_matcher({:route_types, values}, acc) when is_list(values) do
matchers_for_values(acc, :route_type, values)
end

defp build_matcher({:direction_id, value}, acc) when value in [0, 1] do
matchers_for_values(acc, :direction_id, [value])
end

defp build_matcher({:stops, values}, acc) when is_list(values) do
matchers_for_values(acc, :stop, values)
end

defp build_matcher({:activities, values}, acc) when is_list(values) do
# activities are filtered later, no need to add matchers
acc
end

defp matchers_for_values(acc, key, values) do
for value <- values,
matcher <- acc do
Map.put(matcher, key, value)
end
end

defp matches?(alert, matchers) when is_list(matchers) do
matchers
|> Enum.any?(&matches?(alert, &1))
end

defp matches?(alert, matcher) when is_map(matcher) do
Enum.all?(matcher, &matches?(alert, &1))
end

defp matches?(alert, {key, value}) do
Enum.any?(alert.informed_entities, &(Map.get(&1, key) == value))
end
end
1 change: 1 addition & 0 deletions lib/screens/alerts/parser.ex
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ defmodule Screens.Alerts.Parser do

defp parse_informed_entity(ie) do
%{
activities: get_in(ie, ["activities"]),
stop: get_in(ie, ["stop"]),
route: get_in(ie, ["route"]),
route_type: get_in(ie, ["route_type"]),
Expand Down
127 changes: 127 additions & 0 deletions test/screens/alerts/alert_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -107,4 +107,131 @@ defmodule Screens.Alerts.AlertTest do
assert effect == Alert.effect(%Alert{effect: effect})
end
end

describe "fetch_from_cache/2" do
setup do
alerts = [
%Alert{
id: "USING_WHEELCHAIR",
cause: :construction,
effect: :delay,
severity: 4,
header: "Alert 0",
description: "Alert 0",
informed_entities: [
%{
activities: ~w[USING_WHEELCHAIR],
stop: "A",
route: nil,
route_type: 3,
direction_id: nil
}
]
},
%Alert{
id: "stop: A, route_type: 3",
cause: :construction,
effect: :delay,
severity: 4,
header: "Alert 1",
description: "Alert 1",
informed_entities: [
%{activities: ~w[BOARD RIDE], stop: "A", route: nil, route_type: 3, direction_id: nil}
]
},
%Alert{
id: "stop: B, route_type: 3",
cause: :construction,
effect: :delay,
severity: 4,
header: "Alert 2",
description: "Alert 2",
informed_entities: [
%{activities: ~w[BOARD RIDE], stop: "B", route: nil, route_type: 3, direction_id: nil}
]
},
%Alert{
id: "stop: C, route: Z, route_type: 2, direction_id: 0",
cause: :construction,
effect: :delay,
severity: 4,
header: "Alert 3",
description: "Alert 3",
informed_entities: [
%{activities: ~w[BOARD EXIT], stop: "C", route: "Z", route_type: 2, direction_id: 0}
]
},
%Alert{
id: "stop: D, route: Y/Z, route_type: 2, direction_id: 1",
cause: :construction,
effect: :delay,
severity: 4,
header: "Alert 4",
description: "Alert 4",
informed_entities: [
%{activities: ~w[BOARD RIDE], stop: "D", route: "Z", route_type: 2, direction_id: 1},
%{activities: ~w[BOARD RIDE], stop: nil, route: "Y", route_type: 2, direction_id: nil}
]
}
]

[alerts: alerts, get_all_alerts: fn -> alerts end]
end

test "returns all of the alerts", %{alerts: alerts, get_all_alerts: get_all_alerts} do
assert alerts == Alert.fetch_from_cache([], get_all_alerts)
end

test "filters by stops", %{get_all_alerts: get_all_alerts} do
assert [%Alert{id: "stop: A" <> _}] =
Alert.fetch_from_cache([stop_id: "A"], get_all_alerts)

assert [%Alert{id: "stop: B" <> _}] =
Alert.fetch_from_cache([stop_ids: ["B"]], get_all_alerts)

assert [%Alert{id: "stop: A" <> _}, %Alert{id: "stop: B" <> _}] =
Alert.fetch_from_cache([stop_ids: ["A", "B"]], get_all_alerts)
end

test "filters by routes", %{get_all_alerts: get_all_alerts} do
assert [%Alert{id: "stop: C, route: Z" <> _}, %Alert{id: "stop: D, route: Y/Z" <> _}] =
Alert.fetch_from_cache([route_ids: ["Z"]], get_all_alerts)

assert [%Alert{id: "stop: D, route: Y/Z" <> _}] =
Alert.fetch_from_cache([route_ids: ["Y"]], get_all_alerts)
end

test "filters by route_type", %{get_all_alerts: get_all_alerts} do
assert [
%Alert{id: "stop: C, route: Z, route_type: 2" <> _},
%Alert{id: "stop: D, route: Y/Z, route_type: 2" <> _}
] =
Alert.fetch_from_cache([route_type: :rail], get_all_alerts)

assert [
%Alert{id: "stop: C, route: Z, route_type: 2" <> _},
%Alert{id: "stop: D, route: Y/Z, route_type: 2" <> _}
] =
Alert.fetch_from_cache([route_type: 2], get_all_alerts)

assert [
%Alert{id: "stop: A, route_type: 3" <> _},
%Alert{id: "stop: B, route_type: 3" <> _}
] =
Alert.fetch_from_cache([route_types: [:bus]], get_all_alerts)
end

test "filters by route and direction_id", %{get_all_alerts: get_all_alerts} do
assert [%Alert{id: "stop: D, route: Y/Z, route_type: 2, direction_id: 1"}] =
Alert.fetch_from_cache([route_ids: ["Z"], direction_id: 1], get_all_alerts)
end

test "filters by activities when passed", %{get_all_alerts: get_all_alerts} do
assert [%Alert{id: "USING_WHEELCHAIR"}] =
Alert.fetch_from_cache([activities: ["USING_WHEELCHAIR"]], get_all_alerts)

assert [%Alert{id: "stop: C, route: Z" <> _}] =
Alert.fetch_from_cache([activities: ["EXIT", "DOES_NOT_EXIST"]], get_all_alerts)
end
end
end

0 comments on commit 88bc38d

Please sign in to comment.