diff --git a/lib/gc_index_relay/nostr/filter.ex b/lib/gc_index_relay/nostr/filter.ex index 577bb0d..432a1b1 100644 --- a/lib/gc_index_relay/nostr/filter.ex +++ b/lib/gc_index_relay/nostr/filter.ex @@ -1,11 +1,42 @@ defmodule GcIndexRelay.Nostr.Filter do + alias GcIndexRelay.Nostr.Event alias GcIndexRelay.Repo + alias GcIndexRelay.Nostr.Tag import Ecto.Query - @derive [Jason.Encoder] defstruct [:ids, :authors, :kinds, :tags, :since, :until, :limit] - # TODO: Implement tag filtering + @type t :: %__MODULE__{ + ids: [String.t()] | nil, + authors: [String.t()] | nil, + kinds: [integer()] | nil, + tags: %{String.t() => [String.t()]} | nil, + since: integer() | nil, + until: integer() | nil, + limit: pos_integer() | nil + } + + @spec from_map(map()) :: t() + def from_map(map) when is_map(map) do + tags = + for {"#" <> k, v} <- map, + do: {k, v}, + into: %{} + + %__MODULE__{ + ids: map["ids"] || nil, + authors: map["authors"] || nil, + kinds: map["kinds"] || nil, + tags: + case tags do + tags when map_size(tags) == 0 -> nil + tags -> tags + end, + since: map["since"] || nil, + until: map["until"] || nil, + limit: map["limit"] || nil + } + end @doc """ Applies a filter to an Ecto query of Nostr events. @@ -19,7 +50,8 @@ defmodule GcIndexRelay.Nostr.Filter do A filtered list of events in descending order of creation time. """ - def apply(%Ecto.Query{} = query, %__MODULE__{} = filter) do + @spec apply(Ecto.Query.t(), t()) :: [struct()] + def apply(%Ecto.Query{from: %{source: {_table, Event}}} = query, %__MODULE__{} = filter) do query |> apply_ids(filter.ids) |> apply_authors(filter.authors) @@ -27,30 +59,81 @@ defmodule GcIndexRelay.Nostr.Filter do |> apply_since(filter.since) |> apply_until(filter.until) |> preload(:tags) + |> apply_tags(filter.tags) # Always sort in descending order of creation time |> order_by([e], desc: e.created_at) |> apply_limit(filter.limit) |> Repo.all() end + @spec apply_ids(Ecto.Query.t(), [String.t()] | nil) :: Ecto.Query.t() defp apply_ids(query, nil), do: query defp apply_ids(query, []), do: query defp apply_ids(query, ids), do: where(query, [e], e.id in ^ids) + @spec apply_authors(Ecto.Query.t(), [String.t()] | nil) :: Ecto.Query.t() defp apply_authors(query, nil), do: query defp apply_authors(query, []), do: query defp apply_authors(query, authors), do: where(query, [e], e.author in ^authors) + @spec apply_kinds(Ecto.Query.t(), [integer()] | nil) :: Ecto.Query.t() defp apply_kinds(query, nil), do: query defp apply_kinds(query, []), do: query defp apply_kinds(query, kinds), do: where(query, [e], e.kind in ^kinds) + @spec apply_since(Ecto.Query.t(), integer() | nil) :: Ecto.Query.t() defp apply_since(query, nil), do: query defp apply_since(query, since), do: where(query, [e], e.created_at >= ^since) + @spec apply_until(Ecto.Query.t(), integer() | nil) :: Ecto.Query.t() defp apply_until(query, nil), do: query defp apply_until(query, until), do: where(query, [e], e.created_at <= ^until) + @spec apply_tags(Ecto.Query.t(), map() | nil) :: Ecto.Query.t() + defp apply_tags(query, nil), do: query + defp apply_tags(query, tags) when map_size(tags) == 0, do: query + + defp apply_tags(query, tags) do + query = from(e in query, as: :event_query) + + Enum.reduce(tags, query, fn {tag_name, tag_values}, acc_query -> + where( + acc_query, + [e], + exists( + from t in Tag, + where: t.event_id == parent_as(:event_query).id, + where: t.name == ^tag_name, + where: t.value in ^tag_values + ) + ) + end) + end + + @spec apply_limit(Ecto.Query.t(), pos_integer() | nil) :: Ecto.Query.t() defp apply_limit(query, nil), do: query defp apply_limit(query, limit), do: limit(query, ^limit) end + +defimpl Jason.Encoder, for: GcIndexRelay.Nostr.Filter do + alias GcIndexRelay.Nostr.Filter + + def encode(%Filter{} = filter, opts) do + # Prefix single-letter tags with '#' + tags_map = + case filter.tags do + nil -> %{} + tags -> Enum.map(tags, fn {k, v} -> {"#" <> k, v} end) |> Map.new() + end + + # Produce a map from the remaining filter fields + rest_map = + filter + |> Map.from_struct() + |> Map.delete(:tags) + + # Merge the tags into the remaining fields and encode with Jason + Map.merge(rest_map, tags_map) + |> Jason.Encode.map(opts) + end +end