diff options
-rw-r--r-- | lib/pleroma/formatter.ex | 14 | ||||
-rw-r--r-- | lib/pleroma/user.ex | 158 | ||||
-rw-r--r-- | lib/pleroma/web/activity_pub/activity_pub.ex | 16 | ||||
-rw-r--r-- | lib/pleroma/web/activity_pub/relay.ex | 2 | ||||
-rw-r--r-- | lib/pleroma/web/activity_pub/transmogrifier.ex | 3 | ||||
-rw-r--r-- | lib/pleroma/web/activity_pub/utils.ex | 8 | ||||
-rw-r--r-- | lib/pleroma/web/common_api/utils.ex | 14 | ||||
-rw-r--r-- | lib/pleroma/web/mastodon_api/mastodon_api_controller.ex | 6 | ||||
-rw-r--r-- | lib/pleroma/web/twitter_api/twitter_api_controller.ex | 2 | ||||
-rw-r--r-- | priv/repo/migrations/20190115085500_create_user_fts_index.exs | 17 | ||||
-rw-r--r-- | priv/repo/migrations/20190118074940_fix_user_trigram_index.exs | 22 | ||||
-rw-r--r-- | test/formatter_test.exs | 2 | ||||
-rw-r--r-- | test/user_test.exs | 86 | ||||
-rw-r--r-- | test/web/activity_pub/transmogrifier_test.exs | 16 | ||||
-rw-r--r-- | test/web/twitter_api/twitter_api_controller_test.exs | 10 |
15 files changed, 307 insertions, 69 deletions
diff --git a/lib/pleroma/formatter.ex b/lib/pleroma/formatter.ex index 4149265a2..37737853a 100644 --- a/lib/pleroma/formatter.ex +++ b/lib/pleroma/formatter.ex @@ -120,7 +120,7 @@ defmodule Pleroma.Formatter do end @doc "Adds the links to mentioned users" - def add_user_links({subs, text}, mentions) do + def add_user_links({subs, text}, mentions, options \\ []) do mentions = mentions |> Enum.sort_by(fn {name, _} -> -String.length(name) end) @@ -142,12 +142,16 @@ defmodule Pleroma.Formatter do ap_id end - short_match = String.split(match, "@") |> tl() |> hd() + nickname = + if options[:format] == :full do + User.full_nickname(match) + else + User.local_nickname(match) + end {uuid, - "<span class='h-card'><a data-user='#{id}' class='u-url mention' href='#{ap_id}'>@<span>#{ - short_match - }</span></a></span>"} + "<span class='h-card'><a data-user='#{id}' class='u-url mention' href='#{ap_id}'>" <> + "@<span>#{nickname}</span></a></span>"} end) {subs, uuid_text} diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index c91f2d31a..06084b117 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -35,7 +35,7 @@ defmodule Pleroma.User do field(:avatar, :map) field(:local, :boolean, default: true) field(:follower_address, :string) - field(:search_distance, :float, virtual: true) + field(:search_rank, :float, virtual: true) field(:tags, {:array, :string}, default: []) field(:last_refreshed_at, :naive_datetime) has_many(:notifications, Notification) @@ -447,8 +447,7 @@ defmodule Pleroma.User do def get_by_nickname(nickname) do Repo.get_by(User, nickname: nickname) || if Regex.match?(~r(@#{Pleroma.Web.Endpoint.host()})i, nickname) do - [local_nickname, _] = String.split(nickname, "@") - Repo.get_by(User, nickname: local_nickname) + Repo.get_by(User, nickname: local_nickname(nickname)) end end @@ -511,6 +510,12 @@ defmodule Pleroma.User do {:ok, Repo.all(q)} end + def get_followers_ids(user, page \\ nil) do + q = get_followers_query(user, page) + + Repo.all(from(u in q, select: u.id)) + end + def get_friends_query(%User{id: id, following: following}, nil) do from( u in User, @@ -535,6 +540,12 @@ defmodule Pleroma.User do {:ok, Repo.all(q)} end + def get_friends_ids(user, page \\ nil) do + q = get_friends_query(user, page) + + Repo.all(from(u in q, select: u.id)) + end + def get_follow_requests_query(%User{} = user) do from( a in Activity, @@ -666,37 +677,120 @@ defmodule Pleroma.User do Repo.all(query) end - def search(query, resolve \\ false) do - # strip the beginning @ off if there is a query + def search(query, resolve \\ false, for_user \\ nil) do + # Strip the beginning @ off if there is a query query = String.trim_leading(query, "@") - if resolve do - User.get_or_fetch_by_nickname(query) - end + if resolve, do: User.get_or_fetch_by_nickname(query) - inner = - from( - u in User, - select_merge: %{ - search_distance: - fragment( - "? <-> (? || coalesce(?, ''))", - ^query, - u.nickname, - u.name - ) - }, - where: not is_nil(u.nickname) - ) + fts_results = do_search(fts_search_subquery(query), for_user) + + {:ok, trigram_results} = + Repo.transaction(fn -> + Ecto.Adapters.SQL.query(Repo, "select set_limit(0.25)", []) + do_search(trigram_search_subquery(query), for_user) + end) + Enum.uniq_by(fts_results ++ trigram_results, & &1.id) + end + + defp do_search(subquery, for_user, options \\ []) do q = from( - s in subquery(inner), - order_by: s.search_distance, - limit: 20 + s in subquery(subquery), + order_by: [desc: s.search_rank], + limit: ^(options[:limit] || 20) ) - Repo.all(q) + results = + q + |> Repo.all() + |> Enum.filter(&(&1.search_rank > 0)) + + boost_search_results(results, for_user) + end + + defp fts_search_subquery(query) do + processed_query = + query + |> String.replace(~r/\W+/, " ") + |> String.trim() + |> String.split() + |> Enum.map(&(&1 <> ":*")) + |> Enum.join(" | ") + + from( + u in User, + select_merge: %{ + search_rank: + fragment( + """ + ts_rank_cd( + setweight(to_tsvector('simple', regexp_replace(?, '\\W', ' ', 'g')), 'A') || + setweight(to_tsvector('simple', regexp_replace(coalesce(?, ''), '\\W', ' ', 'g')), 'B'), + to_tsquery('simple', ?), + 32 + ) + """, + u.nickname, + u.name, + ^processed_query + ) + }, + where: + fragment( + """ + (setweight(to_tsvector('simple', regexp_replace(?, '\\W', ' ', 'g')), 'A') || + setweight(to_tsvector('simple', regexp_replace(coalesce(?, ''), '\\W', ' ', 'g')), 'B')) @@ to_tsquery('simple', ?) + """, + u.nickname, + u.name, + ^processed_query + ) + ) + end + + defp trigram_search_subquery(query) do + from( + u in User, + select_merge: %{ + search_rank: + fragment( + "similarity(?, trim(? || ' ' || coalesce(?, '')))", + ^query, + u.nickname, + u.name + ) + }, + where: fragment("trim(? || ' ' || coalesce(?, '')) % ?", u.nickname, u.name, ^query) + ) + end + + defp boost_search_results(results, nil), do: results + + defp boost_search_results(results, for_user) do + friends_ids = get_friends_ids(for_user) + followers_ids = get_followers_ids(for_user) + + Enum.map( + results, + fn u -> + search_rank_coef = + cond do + u.id in friends_ids -> + 1.2 + + u.id in followers_ids -> + 1.1 + + true -> + 1 + end + + Map.put(u, :search_rank, u.search_rank * search_rank_coef) + end + ) + |> Enum.sort_by(&(-&1.search_rank)) end def blocks_import(%User{} = blocker, blocked_identifiers) when is_list(blocked_identifiers) do @@ -997,7 +1091,7 @@ defmodule Pleroma.User do end) bio - |> CommonUtils.format_input(mentions, tags, "text/plain") + |> CommonUtils.format_input(mentions, tags, "text/plain", user_links: [format: :full]) |> Formatter.emojify(emoji) end @@ -1048,6 +1142,16 @@ defmodule Pleroma.User do end end + def local_nickname(nickname_or_mention) do + nickname_or_mention + |> full_nickname() + |> String.split("@") + |> hd() + end + + def full_nickname(nickname_or_mention), + do: String.trim_leading(nickname_or_mention, "@") + def error_user(ap_id) do %User{ name: ap_id, diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 5b87f7462..130c06028 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -224,10 +224,11 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do %User{ap_id: _} = user, %Object{data: %{"id" => _}} = object, activity_id \\ nil, - local \\ true + local \\ true, + public \\ true ) do with true <- is_public?(object), - announce_data <- make_announce_data(user, object, activity_id), + announce_data <- make_announce_data(user, object, activity_id, public), {:ok, activity} <- insert(announce_data, local), {:ok, object} <- add_announce_to_object(activity, object), :ok <- maybe_federate(activity) do @@ -796,13 +797,12 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do end end - def is_public?(%Object{data: %{"type" => "Tombstone"}}) do - false - end + def is_public?(%Object{data: %{"type" => "Tombstone"}}), do: false + def is_public?(%Object{data: data}), do: is_public?(data) + def is_public?(%Activity{data: data}), do: is_public?(data) - def is_public?(activity) do - "https://www.w3.org/ns/activitystreams#Public" in (activity.data["to"] ++ - (activity.data["cc"] || [])) + def is_public?(data) do + "https://www.w3.org/ns/activitystreams#Public" in (data["to"] ++ (data["cc"] || [])) end def visible_for_user?(activity, nil) do diff --git a/lib/pleroma/web/activity_pub/relay.ex b/lib/pleroma/web/activity_pub/relay.ex index abddbc790..c0a52e349 100644 --- a/lib/pleroma/web/activity_pub/relay.ex +++ b/lib/pleroma/web/activity_pub/relay.ex @@ -40,7 +40,7 @@ defmodule Pleroma.Web.ActivityPub.Relay do def publish(%Activity{data: %{"type" => "Create"}} = activity) do with %User{} = user <- get_actor(), %Object{} = object <- Object.normalize(activity.data["object"]["id"]) do - ActivityPub.announce(user, object) + ActivityPub.announce(user, object, nil, true, false) else e -> Logger.error("error: #{inspect(e)}") end diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 86d11c874..fa3abe3d8 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -451,7 +451,8 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do with actor <- get_actor(data), %User{} = actor <- User.get_or_fetch_by_ap_id(actor), {:ok, object} <- get_obj_helper(object_id) || fetch_obj_helper(object_id), - {:ok, activity, _object} <- ActivityPub.announce(actor, object, id, false) do + public <- ActivityPub.is_public?(data), + {:ok, activity, _object} <- ActivityPub.announce(actor, object, id, false, public) do {:ok, activity} else _e -> :error diff --git a/lib/pleroma/web/activity_pub/utils.ex b/lib/pleroma/web/activity_pub/utils.ex index 6ecab773c..d2e457a68 100644 --- a/lib/pleroma/web/activity_pub/utils.ex +++ b/lib/pleroma/web/activity_pub/utils.ex @@ -386,9 +386,10 @@ defmodule Pleroma.Web.ActivityPub.Utils do """ # for relayed messages, we only want to send to subscribers def make_announce_data( - %User{ap_id: ap_id, nickname: nil} = user, + %User{ap_id: ap_id} = user, %Object{data: %{"id" => id}} = object, - activity_id + activity_id, + false ) do data = %{ "type" => "Announce", @@ -405,7 +406,8 @@ defmodule Pleroma.Web.ActivityPub.Utils do def make_announce_data( %User{ap_id: ap_id} = user, %Object{data: %{"id" => id}} = object, - activity_id + activity_id, + true ) do data = %{ "type" => "Announce", diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 7e30d224c..a36ab5c15 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -116,16 +116,18 @@ defmodule Pleroma.Web.CommonAPI.Utils do Enum.join([text | attachment_text], "<br>") end + def format_input(text, mentions, tags, format, options \\ []) + @doc """ Formatting text to plain text. """ - def format_input(text, mentions, tags, "text/plain") do + def format_input(text, mentions, tags, "text/plain", options) do text |> Formatter.html_escape("text/plain") |> String.replace(~r/\r?\n/, "<br>") |> (&{[], &1}).() |> Formatter.add_links() - |> Formatter.add_user_links(mentions) + |> Formatter.add_user_links(mentions, options[:user_links] || []) |> Formatter.add_hashtag_links(tags) |> Formatter.finalize() end @@ -133,24 +135,24 @@ defmodule Pleroma.Web.CommonAPI.Utils do @doc """ Formatting text to html. """ - def format_input(text, mentions, _tags, "text/html") do + def format_input(text, mentions, _tags, "text/html", options) do text |> Formatter.html_escape("text/html") |> (&{[], &1}).() - |> Formatter.add_user_links(mentions) + |> Formatter.add_user_links(mentions, options[:user_links] || []) |> Formatter.finalize() end @doc """ Formatting text to markdown. """ - def format_input(text, mentions, tags, "text/markdown") do + def format_input(text, mentions, tags, "text/markdown", options) do text |> Formatter.mentions_escape(mentions) |> Earmark.as_html!() |> Formatter.html_escape("text/html") |> (&{[], &1}).() - |> Formatter.add_user_links(mentions) + |> Formatter.add_user_links(mentions, options[:user_links] || []) |> Formatter.add_hashtag_links(tags) |> Formatter.finalize() end diff --git a/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex b/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex index daad89185..882d336be 100644 --- a/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex @@ -771,7 +771,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do end def search2(%{assigns: %{user: user}} = conn, %{"q" => query} = params) do - accounts = User.search(query, params["resolve"] == "true") + accounts = User.search(query, params["resolve"] == "true", user) statuses = status_search(user, query) @@ -795,7 +795,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do end def search(%{assigns: %{user: user}} = conn, %{"q" => query} = params) do - accounts = User.search(query, params["resolve"] == "true") + accounts = User.search(query, params["resolve"] == "true", user) statuses = status_search(user, query) @@ -816,7 +816,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do end def account_search(%{assigns: %{user: user}} = conn, %{"q" => query} = params) do - accounts = User.search(query, params["resolve"] == "true") + accounts = User.search(query, params["resolve"] == "true", user) res = AccountView.render("accounts.json", users: accounts, for: user, as: :user) diff --git a/lib/pleroma/web/twitter_api/twitter_api_controller.ex b/lib/pleroma/web/twitter_api/twitter_api_controller.ex index 1c728166c..ede079963 100644 --- a/lib/pleroma/web/twitter_api/twitter_api_controller.ex +++ b/lib/pleroma/web/twitter_api/twitter_api_controller.ex @@ -675,7 +675,7 @@ defmodule Pleroma.Web.TwitterAPI.Controller do end def search_user(%{assigns: %{user: user}} = conn, %{"query" => query}) do - users = User.search(query, true) + users = User.search(query, true, user) conn |> put_view(UserView) diff --git a/priv/repo/migrations/20190115085500_create_user_fts_index.exs b/priv/repo/migrations/20190115085500_create_user_fts_index.exs new file mode 100644 index 000000000..499d67113 --- /dev/null +++ b/priv/repo/migrations/20190115085500_create_user_fts_index.exs @@ -0,0 +1,17 @@ +defmodule Pleroma.Repo.Migrations.CreateUserFtsIndex do + use Ecto.Migration + + def change do + create index( + :users, + [ + """ + (setweight(to_tsvector('simple', regexp_replace(nickname, '\\W', ' ', 'g')), 'A') || + setweight(to_tsvector('simple', regexp_replace(coalesce(name, ''), '\\W', ' ', 'g')), 'B')) + """ + ], + name: :users_fts_index, + using: :gin + ) + end +end diff --git a/priv/repo/migrations/20190118074940_fix_user_trigram_index.exs b/priv/repo/migrations/20190118074940_fix_user_trigram_index.exs new file mode 100644 index 000000000..b4e8c984c --- /dev/null +++ b/priv/repo/migrations/20190118074940_fix_user_trigram_index.exs @@ -0,0 +1,22 @@ +defmodule Pleroma.Repo.Migrations.FixUserTrigramIndex do + use Ecto.Migration + + def up do + drop_if_exists(index(:users, [], name: :users_trigram_index)) + + create( + index(:users, ["(trim(nickname || ' ' || coalesce(name, ''))) gist_trgm_ops"], + name: :users_trigram_index, + using: :gist + ) + ) + end + + def down do + drop_if_exists(index(:users, [], name: :users_trigram_index)) + + create( + index(:users, ["(nickname || name) gist_trgm_ops"], name: :users_trigram_index, using: :gist) + ) + end +end diff --git a/test/formatter_test.exs b/test/formatter_test.exs index bd8844458..2e717194b 100644 --- a/test/formatter_test.exs +++ b/test/formatter_test.exs @@ -124,7 +124,7 @@ defmodule Pleroma.FormatterTest do end describe "add_user_links" do - test "gives a replacement for user links" do + test "gives a replacement for user links, using local nicknames in user links text" do text = "@gsimg According to @archa_eme_, that is @daggsy. Also hello @archaeme@archae.me" gsimg = insert(:user, %{nickname: "gsimg"}) diff --git a/test/user_test.exs b/test/user_test.exs index cfccce8d1..092cfc5dc 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -775,14 +775,61 @@ defmodule Pleroma.UserTest do end describe "User.search" do - test "finds a user, ranking by similarity" do - _user = insert(:user, %{name: "lain"}) - _user_two = insert(:user, %{name: "ean"}) - _user_three = insert(:user, %{name: "ebn", nickname: "lain@mastodon.social"}) - user_four = insert(:user, %{nickname: "lain@pleroma.soykaf.com"}) + test "finds a user by full or partial nickname" do + user = insert(:user, %{nickname: "john"}) - assert user_four == - User.search("lain@ple") |> List.first() |> Map.put(:search_distance, nil) + Enum.each(["john", "jo", "j"], fn query -> + assert user == User.search(query) |> List.first() |> Map.put(:search_rank, nil) + end) + end + + test "finds a user by full or partial name" do + user = insert(:user, %{name: "John Doe"}) + + Enum.each(["John Doe", "JOHN", "doe", "j d", "j", "d"], fn query -> + assert user == User.search(query) |> List.first() |> Map.put(:search_rank, nil) + end) + end + + test "finds users, preferring nickname matches over name matches" do + u1 = insert(:user, %{name: "lain", nickname: "nick1"}) + u2 = insert(:user, %{nickname: "lain", name: "nick1"}) + + assert [u2.id, u1.id] == Enum.map(User.search("lain"), & &1.id) + end + + test "finds users, considering density of matched tokens" do + u1 = insert(:user, %{name: "Bar Bar plus Word Word"}) + u2 = insert(:user, %{name: "Word Word Bar Bar Bar"}) + + assert [u2.id, u1.id] == Enum.map(User.search("bar word"), & &1.id) + end + + test "finds users, ranking by similarity" do + u1 = insert(:user, %{name: "lain"}) + _u2 = insert(:user, %{name: "ean"}) + u3 = insert(:user, %{name: "ebn", nickname: "lain@mastodon.social"}) + u4 = insert(:user, %{nickname: "lain@pleroma.soykaf.com"}) + + assert [u4.id, u3.id, u1.id] == Enum.map(User.search("lain@ple"), & &1.id) + end + + test "finds users, handling misspelled requests" do + u1 = insert(:user, %{name: "lain"}) + + assert [u1.id] == Enum.map(User.search("laiin"), & &1.id) + end + + test "finds users, boosting ranks of friends and followers" do + u1 = insert(:user) + u2 = insert(:user, %{name: "Doe"}) + follower = insert(:user, %{name: "Doe"}) + friend = insert(:user, %{name: "Doe"}) + + {:ok, follower} = User.follow(follower, u1) + {:ok, u1} = User.follow(u1, friend) + + assert [friend.id, follower.id, u2.id] == Enum.map(User.search("doe", false, u1), & &1.id) end test "finds a user whose name is nil" do @@ -792,7 +839,15 @@ defmodule Pleroma.UserTest do assert user_two == User.search("lain@pleroma.soykaf.com") |> List.first() - |> Map.put(:search_distance, nil) + |> Map.put(:search_rank, nil) + end + + test "does not yield false-positive matches" do + insert(:user, %{name: "John Doe"}) + + Enum.each(["mary", "a", ""], fn query -> + assert [] == User.search(query) + end) end end @@ -874,4 +929,19 @@ defmodule Pleroma.UserTest do Pleroma.Config.put([:instance, :account_activation_required], false) end end + + describe "parse_bio/2" do + test "preserves hosts in user links text" do + remote_user = insert(:user, local: false, nickname: "nick@domain.com") + user = insert(:user) + bio = "A.k.a. @nick@domain.com" + + expected_text = + "A.k.a. <span class='h-card'><a data-user='#{remote_user.id}' class='u-url mention' href='#{ + remote_user.ap_id + }'>" <> "@<span>nick@domain.com</span></a></span>" + + assert expected_text == User.parse_bio(bio, user) + end + end end diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index 87d0ab559..6107ac4f7 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -288,6 +288,22 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do assert Activity.get_create_activity_by_object_ap_id(data["object"]).id == activity.id end + test "it does not clobber the addressing on announce activities" do + user = insert(:user) + {:ok, activity} = CommonAPI.post(user, %{"status" => "hey"}) + + data = + File.read!("test/fixtures/mastodon-announce.json") + |> Poison.decode!() + |> Map.put("object", activity.data["object"]["id"]) + |> Map.put("to", ["http://mastodon.example.org/users/admin/followers"]) + |> Map.put("cc", []) + + {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + + assert data["to"] == ["http://mastodon.example.org/users/admin/followers"] + end + test "it works for incoming update activities" do data = File.read!("test/fixtures/mastodon-post-activity.json") |> Poison.decode!() diff --git a/test/web/twitter_api/twitter_api_controller_test.exs b/test/web/twitter_api/twitter_api_controller_test.exs index e08edc525..f22cdd870 100644 --- a/test/web/twitter_api/twitter_api_controller_test.exs +++ b/test/web/twitter_api/twitter_api_controller_test.exs @@ -1655,16 +1655,16 @@ defmodule Pleroma.Web.TwitterAPI.ControllerTest do describe "GET /api/pleroma/search_user" do test "it returns users, ordered by similarity", %{conn: conn} do user = insert(:user, %{name: "eal"}) - user_two = insert(:user, %{name: "ean"}) - user_three = insert(:user, %{name: "ebn"}) + user_two = insert(:user, %{name: "eal me"}) + _user_three = insert(:user, %{name: "zzz"}) resp = conn - |> get(twitter_api_search__path(conn, :search_user), query: "eal") + |> get(twitter_api_search__path(conn, :search_user), query: "eal me") |> json_response(200) - assert length(resp) == 3 - assert [user.id, user_two.id, user_three.id] == Enum.map(resp, fn %{"id" => id} -> id end) + assert length(resp) == 2 + assert [user_two.id, user.id] == Enum.map(resp, fn %{"id" => id} -> id end) end end |