Page MenuHomePhorge

No OneTemporary

Authored By
Unknown
Size
24 KB
Referenced Files
None
Subscribers
None
diff --git a/changelog.d/hashtag-search.change b/changelog.d/hashtag-search.change
new file mode 100644
index 000000000..f17e711ce
--- /dev/null
+++ b/changelog.d/hashtag-search.change
@@ -0,0 +1 @@
+Hashtag searches return real results based on words in your query
diff --git a/lib/pleroma/hashtag.ex b/lib/pleroma/hashtag.ex
index fdb564fec..99e6eb39b 100644
--- a/lib/pleroma/hashtag.ex
+++ b/lib/pleroma/hashtag.ex
@@ -1,152 +1,162 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Hashtag do
use Ecto.Schema
import Ecto.Changeset
import Ecto.Query
alias Ecto.Multi
alias Pleroma.Hashtag
alias Pleroma.Object
alias Pleroma.Repo
alias Pleroma.User.HashtagFollow
schema "hashtags" do
field(:name, :string)
many_to_many(:objects, Object, join_through: "hashtags_objects", on_replace: :delete)
timestamps()
end
def normalize_name(name) do
name
|> String.downcase()
|> String.trim()
end
def get_by_id(id) do
Repo.get(Hashtag, id)
end
def get_by_name(name) do
Repo.get_by(Hashtag, name: normalize_name(name))
end
def get_or_create_by_name(name) do
changeset = changeset(%Hashtag{}, %{name: name})
Repo.insert(
changeset,
on_conflict: [set: [name: get_field(changeset, :name)]],
conflict_target: :name,
returning: true
)
end
def get_or_create_by_names(names) when is_list(names) do
names = Enum.map(names, &normalize_name/1)
timestamp = NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second)
structs =
Enum.map(names, fn name ->
%Hashtag{}
|> changeset(%{name: name})
|> Map.get(:changes)
|> Map.merge(%{inserted_at: timestamp, updated_at: timestamp})
end)
try do
with {:ok, %{query_op: hashtags}} <-
Multi.new()
|> Multi.insert_all(:insert_all_op, Hashtag, structs,
on_conflict: :nothing,
conflict_target: :name
)
|> Multi.run(:query_op, fn _repo, _changes ->
{:ok, Repo.all(from(ht in Hashtag, where: ht.name in ^names))}
end)
|> Repo.transaction() do
{:ok, hashtags}
else
{:error, _name, value, _changes_so_far} -> {:error, value}
end
rescue
e -> {:error, e}
end
end
def changeset(%Hashtag{} = struct, params) do
struct
|> cast(params, [:name])
|> update_change(:name, &normalize_name/1)
|> validate_required([:name])
|> unique_constraint(:name)
end
def unlink(%Object{id: object_id}) do
with {_, hashtag_ids} <-
from(hto in "hashtags_objects",
where: hto.object_id == ^object_id,
select: hto.hashtag_id
)
|> Repo.delete_all(),
{:ok, unreferenced_count} <- delete_unreferenced(hashtag_ids) do
{:ok, length(hashtag_ids), unreferenced_count}
end
end
@delete_unreferenced_query """
DELETE FROM hashtags WHERE id IN
(SELECT hashtags.id FROM hashtags
LEFT OUTER JOIN hashtags_objects
ON hashtags_objects.hashtag_id = hashtags.id
WHERE hashtags_objects.hashtag_id IS NULL AND hashtags.id = ANY($1));
"""
def delete_unreferenced(ids) do
with {:ok, %{num_rows: deleted_count}} <- Repo.query(@delete_unreferenced_query, [ids]) do
{:ok, deleted_count}
end
end
def get_followers(%Hashtag{id: hashtag_id}) do
from(hf in HashtagFollow)
|> where([hf], hf.hashtag_id == ^hashtag_id)
|> join(:inner, [hf], u in assoc(hf, :user))
|> select([hf, u], u.id)
|> Repo.all()
end
def get_recipients_for_activity(%Pleroma.Activity{object: %{hashtags: tags}})
when is_list(tags) do
tags
|> Enum.map(&get_followers/1)
|> List.flatten()
|> Enum.uniq()
end
def get_recipients_for_activity(_activity), do: []
def search(query, options \\ []) do
limit = Keyword.get(options, :limit, 20)
offset = Keyword.get(options, :offset, 0)
- query
- |> String.downcase()
- |> String.trim()
- |> then(fn search_term ->
+ search_terms =
+ query
+ |> String.downcase()
+ |> String.trim()
+ |> String.split(~r/\s+/)
+ |> Enum.filter(&(&1 != ""))
+
+ if Enum.empty?(search_terms) do
+ []
+ else
+ # Use PostgreSQL's ANY operator with array for efficient multi-term search
+ # This is much more efficient than multiple OR clauses
+ search_patterns = Enum.map(search_terms, &"%#{&1}%")
+
from(ht in Hashtag,
- where: fragment("LOWER(?) LIKE ?", ht.name, ^"%#{search_term}%"),
+ where: fragment("LOWER(?) LIKE ANY(?)", ht.name, ^search_patterns),
order_by: [asc: ht.name],
limit: ^limit,
offset: ^offset
)
|> Repo.all()
|> Enum.map(& &1.name)
- end)
+ end
end
end
diff --git a/test/pleroma/hashtag_test.exs b/test/pleroma/hashtag_test.exs
index a84effc5d..907c5ff40 100644
--- a/test/pleroma/hashtag_test.exs
+++ b/test/pleroma/hashtag_test.exs
@@ -1,54 +1,98 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.HashtagTest do
use Pleroma.DataCase
alias Pleroma.Hashtag
describe "changeset validations" do
test "ensure non-blank :name" do
changeset = Hashtag.changeset(%Hashtag{}, %{name: ""})
assert {:name, {"can't be blank", [validation: :required]}} in changeset.errors
end
end
describe "search_hashtags" do
test "searches hashtags by partial match" do
{:ok, _} = Hashtag.get_or_create_by_name("car")
{:ok, _} = Hashtag.get_or_create_by_name("racecar")
{:ok, _} = Hashtag.get_or_create_by_name("nascar")
{:ok, _} = Hashtag.get_or_create_by_name("bicycle")
results = Hashtag.search("car")
assert "car" in results
assert "racecar" in results
assert "nascar" in results
refute "bicycle" in results
results = Hashtag.search("race")
assert "racecar" in results
refute "car" in results
refute "nascar" in results
refute "bicycle" in results
results = Hashtag.search("nonexistent")
assert results == []
end
+ test "searches hashtags by multiple words in query" do
+ # Create some hashtags
+ {:ok, _} = Hashtag.get_or_create_by_name("computer")
+ {:ok, _} = Hashtag.get_or_create_by_name("laptop")
+ {:ok, _} = Hashtag.get_or_create_by_name("desktop")
+ {:ok, _} = Hashtag.get_or_create_by_name("phone")
+
+ # Search for "new computer" - should return "computer"
+ results = Hashtag.search("new computer")
+ assert "computer" in results
+ refute "laptop" in results
+ refute "desktop" in results
+ refute "phone" in results
+
+ # Search for "computer laptop" - should return both
+ results = Hashtag.search("computer laptop")
+ assert "computer" in results
+ assert "laptop" in results
+ refute "desktop" in results
+ refute "phone" in results
+
+ # Search for "new phone" - should return "phone"
+ results = Hashtag.search("new phone")
+ assert "phone" in results
+ refute "computer" in results
+ refute "laptop" in results
+ refute "desktop" in results
+ end
+
test "supports pagination" do
{:ok, _} = Hashtag.get_or_create_by_name("alpha")
{:ok, _} = Hashtag.get_or_create_by_name("beta")
{:ok, _} = Hashtag.get_or_create_by_name("gamma")
{:ok, _} = Hashtag.get_or_create_by_name("delta")
results = Hashtag.search("a", limit: 2)
assert length(results) == 2
results = Hashtag.search("a", limit: 2, offset: 1)
assert length(results) == 2
end
+
+ test "handles many search terms efficiently" do
+ # Create hashtags
+ {:ok, _} = Hashtag.get_or_create_by_name("computer")
+ {:ok, _} = Hashtag.get_or_create_by_name("laptop")
+ {:ok, _} = Hashtag.get_or_create_by_name("phone")
+ {:ok, _} = Hashtag.get_or_create_by_name("tablet")
+
+ # Search with many terms - should be efficient with PostgreSQL ANY operator
+ results = Hashtag.search("new fast computer laptop phone tablet device")
+ assert "computer" in results
+ assert "laptop" in results
+ assert "phone" in results
+ assert "tablet" in results
+ end
end
end
diff --git a/test/pleroma/web/mastodon_api/controllers/search_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/search_controller_test.exs
index 1fbc7c9c6..8b4c6add2 100644
--- a/test/pleroma/web/mastodon_api/controllers/search_controller_test.exs
+++ b/test/pleroma/web/mastodon_api/controllers/search_controller_test.exs
@@ -1,435 +1,466 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
use Pleroma.Web.ConnCase
alias Pleroma.Object
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.Endpoint
import Pleroma.Factory
import ExUnit.CaptureLog
import Tesla.Mock
import Mock
setup do
Mox.stub_with(Pleroma.UnstubbedConfigMock, Pleroma.Test.StaticConfig)
:ok
end
setup_all do
mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end)
:ok
end
describe ".search2" do
test "it returns empty result if user or status search return undefined error", %{conn: conn} do
with_mocks [
{Pleroma.User, [], [search: fn _q, _o -> raise "Oops" end]},
{Pleroma.Activity, [], [search: fn _u, _q, _o -> raise "Oops" end]}
] do
capture_log(fn ->
results =
conn
|> get("/api/v2/search?q=2hu")
|> json_response_and_validate_schema(200)
assert results["accounts"] == []
assert results["statuses"] == []
end) =~
"[error] Elixir.Pleroma.Web.MastodonAPI.SearchController search error: %RuntimeError{message: \"Oops\"}"
end
end
@tag :skip_darwin
test "search", %{conn: conn} do
user = insert(:user)
user_two = insert(:user, %{nickname: "shp@shitposter.club"})
user_three = insert(:user, %{nickname: "shp@heldscal.la", name: "I love 2hu"})
{:ok, activity} = CommonAPI.post(user, %{status: "This is about 2hu private 天子"})
{:ok, _activity} =
CommonAPI.post(user, %{
status: "This is about 2hu, but private",
visibility: "private"
})
{:ok, _} = CommonAPI.post(user_two, %{status: "This isn't"})
results =
conn
|> get("/api/v2/search?#{URI.encode_query(%{q: "2hu #private"})}")
|> json_response_and_validate_schema(200)
[account | _] = results["accounts"]
assert account["id"] == to_string(user_three.id)
assert results["hashtags"] == [
%{"name" => "private", "url" => "#{Endpoint.url()}/tag/private"}
]
[status] = results["statuses"]
assert status["id"] == to_string(activity.id)
results =
get(conn, "/api/v2/search?q=天子")
|> json_response_and_validate_schema(200)
assert results["hashtags"] == [
%{"name" => "天子", "url" => "#{Endpoint.url()}/tag/天子"}
]
[status] = results["statuses"]
assert status["id"] == to_string(activity.id)
end
test "search local-only status as an authenticated user" do
user = insert(:user)
%{conn: conn} = oauth_access(["read:search"])
{:ok, activity} =
CommonAPI.post(user, %{status: "This is about 2hu private 天子", visibility: "local"})
results =
conn
|> get("/api/v2/search?#{URI.encode_query(%{q: "2hu"})}")
|> json_response_and_validate_schema(200)
[status] = results["statuses"]
assert status["id"] == to_string(activity.id)
end
test "search local-only status as an unauthenticated user" do
user = insert(:user)
%{conn: conn} = oauth_access([])
{:ok, _activity} =
CommonAPI.post(user, %{status: "This is about 2hu private 天子", visibility: "local"})
results =
conn
|> get("/api/v2/search?#{URI.encode_query(%{q: "2hu"})}")
|> json_response_and_validate_schema(200)
assert [] = results["statuses"]
end
test "search local-only status as an anonymous user" do
user = insert(:user)
{:ok, _activity} =
CommonAPI.post(user, %{status: "This is about 2hu private 天子", visibility: "local"})
results =
build_conn()
|> get("/api/v2/search?#{URI.encode_query(%{q: "2hu"})}")
|> json_response_and_validate_schema(200)
assert [] = results["statuses"]
end
test "returns empty results when no hashtags match", %{conn: conn} do
results =
conn
|> get("/api/v2/search?#{URI.encode_query(%{q: "nonexistent"})}")
|> json_response_and_validate_schema(200)
assert results["hashtags"] == []
end
+ test "searches hashtags by multiple words in query", %{conn: conn} do
+ user = insert(:user)
+
+ {:ok, _activity1} = CommonAPI.post(user, %{status: "This is my new #computer"})
+ {:ok, _activity2} = CommonAPI.post(user, %{status: "Check out this #laptop"})
+ {:ok, _activity3} = CommonAPI.post(user, %{status: "My #desktop setup"})
+ {:ok, _activity4} = CommonAPI.post(user, %{status: "New #phone arrived"})
+
+ results =
+ conn
+ |> get("/api/v2/search?#{URI.encode_query(%{q: "new computer"})}")
+ |> json_response_and_validate_schema(200)
+
+ hashtag_names = Enum.map(results["hashtags"], & &1["name"])
+ assert "computer" in hashtag_names
+ refute "laptop" in hashtag_names
+ refute "desktop" in hashtag_names
+ refute "phone" in hashtag_names
+
+ results =
+ conn
+ |> get("/api/v2/search?#{URI.encode_query(%{q: "computer laptop"})}")
+ |> json_response_and_validate_schema(200)
+
+ hashtag_names = Enum.map(results["hashtags"], & &1["name"])
+ assert "computer" in hashtag_names
+ assert "laptop" in hashtag_names
+ refute "desktop" in hashtag_names
+ refute "phone" in hashtag_names
+ end
+
test "supports pagination of hashtags search results", %{conn: conn} do
user = insert(:user)
{:ok, _activity1} = CommonAPI.post(user, %{status: "First #alpha hashtag"})
{:ok, _activity2} = CommonAPI.post(user, %{status: "Second #beta hashtag"})
{:ok, _activity3} = CommonAPI.post(user, %{status: "Third #gamma hashtag"})
{:ok, _activity4} = CommonAPI.post(user, %{status: "Fourth #delta hashtag"})
results =
conn
|> get("/api/v2/search?#{URI.encode_query(%{q: "a", limit: 2, offset: 1})}")
|> json_response_and_validate_schema(200)
hashtag_names = Enum.map(results["hashtags"], & &1["name"])
# Should return 2 hashtags (alpha, beta, gamma, delta all contain 'a')
# With offset 1, we skip the first one, so we get 2 of the remaining 3
assert length(hashtag_names) == 2
assert Enum.all?(hashtag_names, &String.contains?(&1, "a"))
end
test "searches real hashtags from database", %{conn: conn} do
user = insert(:user)
{:ok, _activity1} = CommonAPI.post(user, %{status: "Check out this #car"})
{:ok, _activity2} = CommonAPI.post(user, %{status: "Fast #racecar on the track"})
{:ok, _activity3} = CommonAPI.post(user, %{status: "NASCAR #nascar racing"})
results =
conn
|> get("/api/v2/search?#{URI.encode_query(%{q: "car"})}")
|> json_response_and_validate_schema(200)
hashtag_names = Enum.map(results["hashtags"], & &1["name"])
# Should return car, racecar, and nascar since they all contain "car"
assert "car" in hashtag_names
assert "racecar" in hashtag_names
assert "nascar" in hashtag_names
# Search for "race" - should return racecar
results =
conn
|> get("/api/v2/search?#{URI.encode_query(%{q: "race"})}")
|> json_response_and_validate_schema(200)
hashtag_names = Enum.map(results["hashtags"], & &1["name"])
assert "racecar" in hashtag_names
refute "car" in hashtag_names
refute "nascar" in hashtag_names
end
test "excludes a blocked users from search results", %{conn: conn} do
user = insert(:user)
user_smith = insert(:user, %{nickname: "Agent", name: "I love 2hu"})
user_neo = insert(:user, %{nickname: "Agent Neo", name: "Agent"})
{:ok, act1} = CommonAPI.post(user, %{status: "This is about 2hu private 天子"})
{:ok, act2} = CommonAPI.post(user_smith, %{status: "Agent Smith"})
{:ok, act3} = CommonAPI.post(user_neo, %{status: "Agent Smith"})
Pleroma.User.block(user, user_smith)
results =
conn
|> assign(:user, user)
|> assign(:token, insert(:oauth_token, user: user, scopes: ["read"]))
|> get("/api/v2/search?q=Agent")
|> json_response_and_validate_schema(200)
status_ids = Enum.map(results["statuses"], fn g -> g["id"] end)
assert act3.id in status_ids
refute act2.id in status_ids
refute act1.id in status_ids
end
end
describe ".account_search" do
test "account search", %{conn: conn} do
user_two = insert(:user, %{nickname: "shp@shitposter.club"})
user_three = insert(:user, %{nickname: "shp@heldscal.la", name: "I love 2hu"})
results =
conn
|> get("/api/v1/accounts/search?q=shp")
|> json_response_and_validate_schema(200)
result_ids = for result <- results, do: result["acct"]
assert user_two.nickname in result_ids
assert user_three.nickname in result_ids
results =
conn
|> get("/api/v1/accounts/search?q=2hu")
|> json_response_and_validate_schema(200)
result_ids = for result <- results, do: result["acct"]
assert user_three.nickname in result_ids
end
test "returns account if query contains a space", %{conn: conn} do
insert(:user, %{nickname: "shp@shitposter.club"})
results =
conn
|> get("/api/v1/accounts/search?q=shp@shitposter.club xxx")
|> json_response_and_validate_schema(200)
assert length(results) == 1
end
end
describe ".search" do
test "it returns empty result if user or status search return undefined error", %{conn: conn} do
with_mocks [
{Pleroma.User, [], [search: fn _q, _o -> raise "Oops" end]},
{Pleroma.Activity, [], [search: fn _u, _q, _o -> raise "Oops" end]}
] do
capture_log(fn ->
results =
conn
|> get("/api/v1/search?q=2hu")
|> json_response_and_validate_schema(200)
assert results["accounts"] == []
assert results["statuses"] == []
end) =~
"[error] Elixir.Pleroma.Web.MastodonAPI.SearchController search error: %RuntimeError{message: \"Oops\"}"
end
end
test "search", %{conn: conn} do
user = insert(:user)
user_two = insert(:user, %{nickname: "shp@shitposter.club"})
user_three = insert(:user, %{nickname: "shp@heldscal.la", name: "I love 2hu"})
{:ok, activity} = CommonAPI.post(user, %{status: "This is about 2hu"})
{:ok, _activity} =
CommonAPI.post(user, %{
status: "This is about 2hu, but private",
visibility: "private"
})
{:ok, _} = CommonAPI.post(user_two, %{status: "This isn't"})
results =
conn
|> get("/api/v1/search?q=2hu")
|> json_response_and_validate_schema(200)
[account | _] = results["accounts"]
assert account["id"] == to_string(user_three.id)
assert results["hashtags"] == []
[status] = results["statuses"]
assert status["id"] == to_string(activity.id)
end
test "search fetches remote statuses and prefers them over other results", %{conn: conn} do
{:ok, %{id: activity_id}} =
CommonAPI.post(insert(:user), %{
status: "check out http://mastodon.example.org/@admin/99541947525187367"
})
%{"url" => result_url, "id" => result_id} =
conn
|> get("/api/v1/search?q=http://mastodon.example.org/@admin/99541947525187367")
|> json_response_and_validate_schema(200)
|> Map.get("statuses")
|> List.first()
refute match?(^result_id, activity_id)
assert match?(^result_url, "http://mastodon.example.org/@admin/99541947525187367")
end
test "search doesn't show statuses that it shouldn't", %{conn: conn} do
{:ok, activity} =
CommonAPI.post(insert(:user), %{
status: "This is about 2hu, but private",
visibility: "private"
})
capture_log(fn ->
q = Object.normalize(activity, fetch: false).data["id"]
results =
conn
|> get("/api/v1/search?q=#{q}")
|> json_response_and_validate_schema(200)
[] = results["statuses"]
end)
end
test "search fetches remote accounts", %{conn: conn} do
user = insert(:user)
query = URI.encode_query(%{q: " mike@osada.macgirvin.com ", resolve: true})
results =
conn
|> assign(:user, user)
|> assign(:token, insert(:oauth_token, user: user, scopes: ["read"]))
|> get("/api/v1/search?#{query}")
|> json_response_and_validate_schema(200)
[account] = results["accounts"]
assert account["acct"] == "mike@osada.macgirvin.com"
end
test "search doesn't fetch remote accounts if resolve is false", %{conn: conn} do
results =
conn
|> get("/api/v1/search?q=mike@osada.macgirvin.com&resolve=false")
|> json_response_and_validate_schema(200)
assert [] == results["accounts"]
end
test "search with limit and offset", %{conn: conn} do
user = insert(:user)
_user_two = insert(:user, %{nickname: "shp@shitposter.club"})
_user_three = insert(:user, %{nickname: "shp@heldscal.la", name: "I love 2hu"})
{:ok, _activity1} = CommonAPI.post(user, %{status: "This is about 2hu"})
{:ok, _activity2} = CommonAPI.post(user, %{status: "This is also about 2hu"})
result =
conn
|> get("/api/v1/search?q=2hu&limit=1")
assert results = json_response_and_validate_schema(result, 200)
assert [%{"id" => activity_id1}] = results["statuses"]
assert [_] = results["accounts"]
results =
conn
|> get("/api/v1/search?q=2hu&limit=1&offset=1")
|> json_response_and_validate_schema(200)
assert [%{"id" => activity_id2}] = results["statuses"]
assert [] = results["accounts"]
assert activity_id1 != activity_id2
end
test "search returns results only for the given type", %{conn: conn} do
user = insert(:user)
_user_two = insert(:user, %{nickname: "shp@heldscal.la", name: "I love 2hu"})
{:ok, _activity} = CommonAPI.post(user, %{status: "This is about 2hu"})
assert %{"statuses" => [_activity], "accounts" => [], "hashtags" => []} =
conn
|> get("/api/v1/search?q=2hu&type=statuses")
|> json_response_and_validate_schema(200)
assert %{"statuses" => [], "accounts" => [_user_two], "hashtags" => []} =
conn
|> get("/api/v1/search?q=2hu&type=accounts")
|> json_response_and_validate_schema(200)
end
test "search uses account_id to filter statuses by the author", %{conn: conn} do
user = insert(:user, %{nickname: "shp@shitposter.club"})
user_two = insert(:user, %{nickname: "shp@heldscal.la", name: "I love 2hu"})
{:ok, activity1} = CommonAPI.post(user, %{status: "This is about 2hu"})
{:ok, activity2} = CommonAPI.post(user_two, %{status: "This is also about 2hu"})
results =
conn
|> get("/api/v1/search?q=2hu&account_id=#{user.id}")
|> json_response_and_validate_schema(200)
assert [%{"id" => activity_id1}] = results["statuses"]
assert activity_id1 == activity1.id
assert [_] = results["accounts"]
results =
conn
|> get("/api/v1/search?q=2hu&account_id=#{user_two.id}")
|> json_response_and_validate_schema(200)
assert [%{"id" => activity_id2}] = results["statuses"]
assert activity_id2 == activity2.id
end
end
end

File Metadata

Mime Type
text/x-diff
Expires
Fri, Oct 30, 3:52 AM (1 d, 3 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1799373
Default Alt Text
(24 KB)

Event Timeline