Hashtag searches now return real results from the database

This commit is contained in:
Mark Felder 2025-07-31 17:35:11 -07:00
commit 26fe604942
4 changed files with 109 additions and 125 deletions

View file

@ -130,4 +130,23 @@ defmodule Pleroma.Hashtag do
end end
def get_recipients_for_activity(_activity), do: [] 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 ->
from(ht in Hashtag,
where: fragment("LOWER(?) LIKE ?", ht.name, ^"%#{search_term}%"),
order_by: [asc: ht.name],
limit: ^limit,
offset: ^offset
)
|> Repo.all()
|> Enum.map(& &1.name)
end)
end
end end

View file

@ -7,6 +7,7 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do
alias Pleroma.Repo alias Pleroma.Repo
alias Pleroma.User alias Pleroma.User
alias Pleroma.Hashtag
alias Pleroma.Web.ControllerHelper alias Pleroma.Web.ControllerHelper
alias Pleroma.Web.Endpoint alias Pleroma.Web.Endpoint
alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MastodonAPI.AccountView
@ -120,69 +121,14 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do
defp resource_search(:v2, "hashtags", query, options) do defp resource_search(:v2, "hashtags", query, options) do
tags_path = Endpoint.url() <> "/tag/" tags_path = Endpoint.url() <> "/tag/"
query Hashtag.search(query, options)
|> prepare_tags(options)
|> Enum.map(fn tag -> |> Enum.map(fn tag ->
%{name: tag, url: tags_path <> tag} %{name: tag, url: tags_path <> tag}
end) end)
end end
defp resource_search(:v1, "hashtags", query, options) do defp resource_search(:v1, "hashtags", query, options) do
prepare_tags(query, options) Hashtag.search(query, options)
end
defp prepare_tags(query, options) do
tags =
query
|> preprocess_uri_query()
|> String.split(~r/[^#\w]+/u, trim: true)
|> Enum.uniq_by(&String.downcase/1)
explicit_tags = Enum.filter(tags, fn tag -> String.starts_with?(tag, "#") end)
tags =
if Enum.any?(explicit_tags) do
explicit_tags
else
tags
end
tags = Enum.map(tags, fn tag -> String.trim_leading(tag, "#") end)
tags =
if Enum.empty?(explicit_tags) && !options[:skip_joined_tag] do
add_joined_tag(tags)
else
tags
end
Pleroma.Pagination.paginate_list(tags, options)
end
defp add_joined_tag(tags) do
tags
|> Kernel.++([joined_tag(tags)])
|> Enum.uniq_by(&String.downcase/1)
end
# If `query` is a URI, returns last component of its path, otherwise returns `query`
defp preprocess_uri_query(query) do
if query =~ ~r/https?:\/\// do
query
|> String.trim_trailing("/")
|> URI.parse()
|> Map.get(:path)
|> String.split("/")
|> Enum.at(-1)
else
query
end
end
defp joined_tag(tags) do
tags
|> Enum.map(fn tag -> String.capitalize(tag) end)
|> Enum.join()
end end
defp with_fallback(f, fallback \\ []) do defp with_fallback(f, fallback \\ []) do

View file

@ -14,4 +14,41 @@ defmodule Pleroma.HashtagTest do
assert {:name, {"can't be blank", [validation: :required]}} in changeset.errors assert {:name, {"can't be blank", [validation: :required]}} in changeset.errors
end end
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 "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
end
end end

View file

@ -130,84 +130,66 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
assert [] = results["statuses"] assert [] = results["statuses"]
end end
test "constructs hashtags from search query", %{conn: conn} do test "returns empty results when no hashtags match", %{conn: conn} do
results = results =
conn conn
|> get("/api/v2/search?#{URI.encode_query(%{q: "some text with #explicit #hashtags"})}") |> get("/api/v2/search?#{URI.encode_query(%{q: "nonexistent"})}")
|> json_response_and_validate_schema(200) |> json_response_and_validate_schema(200)
assert results["hashtags"] == [ assert results["hashtags"] == []
%{"name" => "explicit", "url" => "#{Endpoint.url()}/tag/explicit"},
%{"name" => "hashtags", "url" => "#{Endpoint.url()}/tag/hashtags"}
]
results =
conn
|> get("/api/v2/search?#{URI.encode_query(%{q: "john doe JOHN DOE"})}")
|> json_response_and_validate_schema(200)
assert results["hashtags"] == [
%{"name" => "john", "url" => "#{Endpoint.url()}/tag/john"},
%{"name" => "doe", "url" => "#{Endpoint.url()}/tag/doe"},
%{"name" => "JohnDoe", "url" => "#{Endpoint.url()}/tag/JohnDoe"}
]
results =
conn
|> get("/api/v2/search?#{URI.encode_query(%{q: "accident-prone"})}")
|> json_response_and_validate_schema(200)
assert results["hashtags"] == [
%{"name" => "accident", "url" => "#{Endpoint.url()}/tag/accident"},
%{"name" => "prone", "url" => "#{Endpoint.url()}/tag/prone"},
%{"name" => "AccidentProne", "url" => "#{Endpoint.url()}/tag/AccidentProne"}
]
results =
conn
|> get("/api/v2/search?#{URI.encode_query(%{q: "https://shpposter.club/users/shpuld"})}")
|> json_response_and_validate_schema(200)
assert results["hashtags"] == [
%{"name" => "shpuld", "url" => "#{Endpoint.url()}/tag/shpuld"}
]
results =
conn
|> get(
"/api/v2/search?#{URI.encode_query(%{q: "https://www.washingtonpost.com/sports/2020/06/10/" <> "nascar-ban-display-confederate-flag-all-events-properties/"})}"
)
|> json_response_and_validate_schema(200)
assert results["hashtags"] == [
%{"name" => "nascar", "url" => "#{Endpoint.url()}/tag/nascar"},
%{"name" => "ban", "url" => "#{Endpoint.url()}/tag/ban"},
%{"name" => "display", "url" => "#{Endpoint.url()}/tag/display"},
%{"name" => "confederate", "url" => "#{Endpoint.url()}/tag/confederate"},
%{"name" => "flag", "url" => "#{Endpoint.url()}/tag/flag"},
%{"name" => "all", "url" => "#{Endpoint.url()}/tag/all"},
%{"name" => "events", "url" => "#{Endpoint.url()}/tag/events"},
%{"name" => "properties", "url" => "#{Endpoint.url()}/tag/properties"},
%{
"name" => "NascarBanDisplayConfederateFlagAllEventsProperties",
"url" =>
"#{Endpoint.url()}/tag/NascarBanDisplayConfederateFlagAllEventsProperties"
}
]
end end
test "supports pagination of hashtags search results", %{conn: conn} do 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 = results =
conn conn
|> get( |> get("/api/v2/search?#{URI.encode_query(%{q: "a", limit: 2, offset: 1})}")
"/api/v2/search?#{URI.encode_query(%{q: "#some #text #with #hashtags", limit: 2, offset: 1})}"
)
|> json_response_and_validate_schema(200) |> json_response_and_validate_schema(200)
assert results["hashtags"] == [ hashtag_names = Enum.map(results["hashtags"], & &1["name"])
%{"name" => "text", "url" => "#{Endpoint.url()}/tag/text"},
%{"name" => "with", "url" => "#{Endpoint.url()}/tag/with"} # 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 end
test "excludes a blocked users from search results", %{conn: conn} do test "excludes a blocked users from search results", %{conn: conn} do
@ -314,7 +296,7 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
[account | _] = results["accounts"] [account | _] = results["accounts"]
assert account["id"] == to_string(user_three.id) assert account["id"] == to_string(user_three.id)
assert results["hashtags"] == ["2hu"] assert results["hashtags"] == []
[status] = results["statuses"] [status] = results["statuses"]
assert status["id"] == to_string(activity.id) assert status["id"] == to_string(activity.id)