Merge branch 'improve-search' into 'develop'
[akkoma] / lib / pleroma / user / search.ex
1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
3 # SPDX-License-Identifier: AGPL-3.0-only
4
5 defmodule Pleroma.User.Search do
6 alias Pleroma.Repo
7 alias Pleroma.User
8 import Ecto.Query
9
10 def search(query, opts \\ []) do
11 resolve = Keyword.get(opts, :resolve, false)
12 for_user = Keyword.get(opts, :for_user)
13
14 # Strip the beginning @ off if there is a query
15 query = String.trim_leading(query, "@")
16
17 maybe_resolve(resolve, for_user, query)
18
19 {:ok, results} =
20 Repo.transaction(fn ->
21 Ecto.Adapters.SQL.query(Repo, "select set_limit(0.25)", [])
22
23 query
24 |> search_query(for_user)
25 |> Repo.all()
26 end)
27
28 results
29 end
30
31 defp maybe_resolve(true, %User{}, query) do
32 User.get_or_fetch(query)
33 end
34
35 defp maybe_resolve(true, _, query) do
36 unless restrict_local?(), do: User.get_or_fetch(query)
37 end
38
39 defp maybe_resolve(_, _, _), do: :noop
40
41 defp search_query(query, for_user) do
42 query
43 |> union_query()
44 |> distinct_query()
45 |> boost_search_rank_query(for_user)
46 |> subquery()
47 |> order_by(desc: :search_rank)
48 |> limit(20)
49 |> maybe_restrict_local(for_user)
50 end
51
52 defp restrict_local? do
53 Pleroma.Config.get([:instance, :limit_unauthenticated_to_local_content], true)
54 end
55
56 defp union_query(query) do
57 fts_subquery = fts_search_subquery(query)
58 trigram_subquery = trigram_search_subquery(query)
59
60 from(s in trigram_subquery, union_all: ^fts_subquery)
61 end
62
63 defp distinct_query(q) do
64 from(s in subquery(q), order_by: s.search_type, distinct: s.id)
65 end
66
67 # unauthenticated users can only search local activities
68 defp maybe_restrict_local(q, %User{}), do: q
69
70 defp maybe_restrict_local(q, _) do
71 if restrict_local?() do
72 where(q, [u], u.local == true)
73 else
74 q
75 end
76 end
77
78 defp boost_search_rank_query(query, nil), do: query
79
80 defp boost_search_rank_query(query, for_user) do
81 friends_ids = User.get_friends_ids(for_user)
82 followers_ids = User.get_followers_ids(for_user)
83
84 from(u in subquery(query),
85 select_merge: %{
86 search_rank:
87 fragment(
88 """
89 CASE WHEN (?) THEN (?) * 1.3
90 WHEN (?) THEN (?) * 1.2
91 WHEN (?) THEN (?) * 1.1
92 ELSE (?) END
93 """,
94 u.id in ^friends_ids and u.id in ^followers_ids,
95 u.search_rank,
96 u.id in ^friends_ids,
97 u.search_rank,
98 u.id in ^followers_ids,
99 u.search_rank,
100 u.search_rank
101 )
102 }
103 )
104 end
105
106 defp fts_search_subquery(term, query \\ User) do
107 processed_query =
108 term
109 |> String.replace(~r/\W+/, " ")
110 |> String.trim()
111 |> String.split()
112 |> Enum.map(&(&1 <> ":*"))
113 |> Enum.join(" | ")
114
115 from(
116 u in query,
117 select_merge: %{
118 search_type: ^0,
119 search_rank:
120 fragment(
121 """
122 ts_rank_cd(
123 setweight(to_tsvector('simple', regexp_replace(?, '\\W', ' ', 'g')), 'A') ||
124 setweight(to_tsvector('simple', regexp_replace(coalesce(?, ''), '\\W', ' ', 'g')), 'B'),
125 to_tsquery('simple', ?),
126 32
127 )
128 """,
129 u.nickname,
130 u.name,
131 ^processed_query
132 )
133 },
134 where:
135 fragment(
136 """
137 (setweight(to_tsvector('simple', regexp_replace(?, '\\W', ' ', 'g')), 'A') ||
138 setweight(to_tsvector('simple', regexp_replace(coalesce(?, ''), '\\W', ' ', 'g')), 'B')) @@ to_tsquery('simple', ?)
139 """,
140 u.nickname,
141 u.name,
142 ^processed_query
143 )
144 )
145 |> User.restrict_deactivated()
146 end
147
148 defp trigram_search_subquery(term) do
149 from(
150 u in User,
151 select_merge: %{
152 # ^1 gives 'Postgrex expected a binary, got 1' for some weird reason
153 search_type: fragment("?", 1),
154 search_rank:
155 fragment(
156 "similarity(?, trim(? || ' ' || coalesce(?, '')))",
157 ^term,
158 u.nickname,
159 u.name
160 )
161 },
162 where: fragment("trim(? || ' ' || coalesce(?, '')) % ?", u.nickname, u.name, ^term)
163 )
164 |> User.restrict_deactivated()
165 end
166 end