X-Git-Url: http://git.squeep.com/?a=blobdiff_plain;f=lib%2Fpleroma%2Fuser%2Fsearch.ex;h=6b55df483b76944ba6276f25033ea30f76841628;hb=4ead0d564fa60b1a908f6237474250d6bda85ae9;hp=64eb6d2bc484df842a22562b7468d80d807aba86;hpb=1179a135333aa59e7fa4f5b908e1db8257b994f5;p=akkoma diff --git a/lib/pleroma/user/search.ex b/lib/pleroma/user/search.ex index 64eb6d2bc..2dab67211 100644 --- a/lib/pleroma/user/search.ex +++ b/lib/pleroma/user/search.ex @@ -1,13 +1,14 @@ # Pleroma: A lightweight social networking server -# Copyright © 2017-2019 Pleroma Authors +# Copyright © 2017-2020 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.User.Search do - alias Pleroma.Repo + alias Pleroma.EctoType.ActivityPub.ObjectValidators.Uri, as: UriType + alias Pleroma.Pagination alias Pleroma.User + import Ecto.Query - @similarity_threshold 0.25 @limit 20 def search(query_string, opts \\ []) do @@ -18,83 +19,181 @@ defmodule Pleroma.User.Search do for_user = Keyword.get(opts, :for_user) - # Strip the beginning @ off if there is a query - query_string = String.trim_leading(query_string, "@") + query_string = format_query(query_string) - maybe_resolve(resolve, for_user, query_string) + # If this returns anything, it should bounce to the top + maybe_resolved = maybe_resolve(resolve, for_user, query_string) - {:ok, results} = - Repo.transaction(fn -> - Ecto.Adapters.SQL.query( - Repo, - "select set_limit(#{@similarity_threshold})", - [] - ) + top_user_ids = + [] + |> maybe_add_resolved(maybe_resolved) + |> maybe_add_ap_id_match(query_string) + |> maybe_add_uri_match(query_string) - query_string - |> search_query(for_user, following) - |> paginate(result_limit, offset) - |> Repo.all() - end) + results = + query_string + |> search_query(for_user, following, top_user_ids) + |> Pagination.fetch_paginated(%{"offset" => offset, "limit" => result_limit}, :offset) results end - defp search_query(query_string, for_user, following) do + defp maybe_add_resolved(list, {:ok, %User{} = user}) do + [user.id | list] + end + + defp maybe_add_resolved(list, _), do: list + + defp maybe_add_ap_id_match(list, query) do + if user = User.get_cached_by_ap_id(query) do + [user.id | list] + else + list + end + end + + defp maybe_add_uri_match(list, query) do + with {:ok, query} <- UriType.cast(query), + q = from(u in User, where: u.uri == ^query, select: u.id), + users = Pleroma.Repo.all(q) do + users ++ list + else + _ -> list + end + end + + defp format_query(query_string) do + # Strip the beginning @ off if there is a query + query_string = String.trim_leading(query_string, "@") + + with [name, domain] <- String.split(query_string, "@") do + encoded_domain = + domain + |> String.replace(~r/[!-\-|@|[-`|{-~|\/|:|\s]+/, "") + |> String.to_charlist() + |> :idna.encode() + |> to_string() + + name <> "@" <> encoded_domain + else + _ -> query_string + end + end + + defp search_query(query_string, for_user, following, top_user_ids) do for_user |> base_query(following) |> filter_blocked_user(for_user) + |> filter_invisible_users() + |> filter_discoverable_users() + |> filter_internal_users() |> filter_blocked_domains(for_user) - |> search_subqueries(query_string) - |> union_subqueries - |> distinct_query() - |> boost_search_rank_query(for_user) + |> fts_search(query_string) + |> select_top_users(top_user_ids) + |> trigram_rank(query_string) + |> boost_search_rank(for_user, top_user_ids) |> subquery() |> order_by(desc: :search_rank) |> maybe_restrict_local(for_user) end - defp base_query(_user, false), do: User - defp base_query(user, true), do: User.get_followers_query(user) - - defp filter_blocked_user(query, %User{info: %{blocks: blocks}}) - when length(blocks) > 0 do - from(q in query, where: not (q.ap_id in ^blocks)) + defp select_top_users(query, top_user_ids) do + from(u in query, + or_where: u.id in ^top_user_ids + ) end - defp filter_blocked_user(query, _), do: query + defp fts_search(query, query_string) do + query_string = to_tsquery(query_string) - defp filter_blocked_domains(query, %User{info: %{domain_blocks: domain_blocks}}) - when length(domain_blocks) > 0 do - domains = Enum.join(domain_blocks, ",") + from( + u in query, + where: + fragment( + # The fragment must _exactly_ match `users_fts_index`, otherwise the index won't work + """ + ( + 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, + ^query_string + ) + ) + end + + defp to_tsquery(query_string) do + String.trim_trailing(query_string, "@" <> local_domain()) + |> String.replace(~r/[!-\/|@|[-`|{-~|:-?]+/, " ") + |> String.trim() + |> String.split() + |> Enum.map(&(&1 <> ":*")) + |> Enum.join(" | ") + end + # Considers nickname match, localized nickname match, name match; preferences nickname match + defp trigram_rank(query, query_string) do from( - q in query, - where: fragment("substring(ap_id from '.*://([^/]*)') NOT IN (?)", ^domains) + u in query, + select_merge: %{ + search_rank: + fragment( + """ + similarity(?, ?) + + similarity(?, regexp_replace(?, '@.+', '')) + + similarity(?, trim(coalesce(?, ''))) + """, + ^query_string, + u.nickname, + ^query_string, + u.nickname, + ^query_string, + u.name + ) + } ) end - defp filter_blocked_domains(query, _), do: query + defp base_query(%User{} = user, true), do: User.get_friends_query(user) + defp base_query(_user, _following), do: User - defp paginate(query, limit, offset) do - from(q in query, limit: ^limit, offset: ^offset) + defp filter_invisible_users(query) do + from(q in query, where: q.invisible == false) end - defp union_subqueries({fts_subquery, trigram_subquery}) do - from(s in trigram_subquery, union_all: ^fts_subquery) + defp filter_discoverable_users(query) do + from(q in query, where: q.is_discoverable == true) end - defp search_subqueries(base_query, query_string) do - { - fts_search_subquery(base_query, query_string), - trigram_search_subquery(base_query, query_string) - } + defp filter_internal_users(query) do + from(q in query, where: q.actor_type != "Application") + end + + defp filter_blocked_user(query, %User{} = blocker) do + query + |> join(:left, [u], b in Pleroma.UserRelationship, + as: :blocks, + on: b.relationship_type == ^:block and b.source_id == ^blocker.id and u.id == b.target_id + ) + |> where([blocks: b], is_nil(b.target_id)) end - defp distinct_query(q) do - from(s in subquery(q), order_by: s.search_type, distinct: s.id) + defp filter_blocked_user(query, _), do: query + + defp filter_blocked_domains(query, %User{domain_blocks: domain_blocks}) + when length(domain_blocks) > 0 do + domains = Enum.join(domain_blocks, ",") + + from( + q in query, + where: fragment("substring(ap_id from '.*://([^/]*)') NOT IN (?)", ^domains) + ) end + defp filter_blocked_domains(query, _), do: query + defp maybe_resolve(true, user, query) do case {limit(), user} do {:all, _} -> :noop @@ -119,9 +218,9 @@ defmodule Pleroma.User.Search do defp restrict_local(q), do: where(q, [u], u.local == true) - defp boost_search_rank_query(query, nil), do: query + defp local_domain, do: Pleroma.Config.get([Pleroma.Web.Endpoint, :url, :host]) - defp boost_search_rank_query(query, for_user) do + defp boost_search_rank(query, %User{} = for_user, top_user_ids) do friends_ids = User.get_friends_ids(for_user) followers_ids = User.get_followers_ids(for_user) @@ -130,9 +229,10 @@ defmodule Pleroma.User.Search do search_rank: fragment( """ - CASE WHEN (?) THEN 0.5 + (?) * 1.3 - WHEN (?) THEN 0.5 + (?) * 1.2 + CASE WHEN (?) THEN (?) * 1.5 + WHEN (?) THEN (?) * 1.3 WHEN (?) THEN (?) * 1.1 + WHEN (?) THEN 9001 ELSE (?) END """, u.id in ^friends_ids and u.id in ^followers_ids, @@ -141,76 +241,26 @@ defmodule Pleroma.User.Search do u.search_rank, u.id in ^followers_ids, u.search_rank, + u.id in ^top_user_ids, u.search_rank ) } ) end - @spec fts_search_subquery(User.t() | Ecto.Query.t(), String.t()) :: Ecto.Query.t() - defp fts_search_subquery(query, term) do - processed_query = - String.trim_trailing(term, "@" <> local_domain()) - |> String.replace(~r/\W+/, " ") - |> String.trim() - |> String.split() - |> Enum.map(&(&1 <> ":*")) - |> Enum.join(" | ") - - from( - u in query, + defp boost_search_rank(query, _for_user, top_user_ids) do + from(u in subquery(query), select_merge: %{ - search_type: ^0, 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 - ) + CASE WHEN (?) THEN 9001 + ELSE (?) END """, - 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 - ) - ) - |> User.restrict_deactivated() - end - - @spec trigram_search_subquery(User.t() | Ecto.Query.t(), String.t()) :: Ecto.Query.t() - defp trigram_search_subquery(query, term) do - term = String.trim_trailing(term, "@" <> local_domain()) - - from( - u in query, - select_merge: %{ - # ^1 gives 'Postgrex expected a binary, got 1' for some weird reason - search_type: fragment("?", 1), - search_rank: - fragment( - "similarity(?, trim(? || ' ' || coalesce(?, '')))", - ^term, - u.nickname, - u.name + u.id in ^top_user_ids, + u.search_rank ) - }, - where: fragment("trim(? || ' ' || coalesce(?, '')) % ?", u.nickname, u.name, ^term) + } ) - |> User.restrict_deactivated() end - - defp local_domain, do: Pleroma.Config.get([Pleroma.Web.Endpoint, :url, :host]) end