commit: 26fe60494246121b59e40898e6b950e61853452c
parent d24e6eaf391157a1c465d53924189d6cf4bc22e6
Author: Mark Felder <feld@feld.me>
Date: Thu, 31 Jul 2025 17:35:11 -0700
Hashtag searches now return real results from the database
Diffstat:
4 files changed, 97 insertions(+), 113 deletions(-)
diff --git a/lib/pleroma/hashtag.ex b/lib/pleroma/hashtag.ex
@@ -130,4 +130,23 @@ defmodule Pleroma.Hashtag do
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 ->
+ 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
diff --git a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex
@@ -7,6 +7,7 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do
alias Pleroma.Repo
alias Pleroma.User
+ alias Pleroma.Hashtag
alias Pleroma.Web.ControllerHelper
alias Pleroma.Web.Endpoint
alias Pleroma.Web.MastodonAPI.AccountView
@@ -120,69 +121,14 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do
defp resource_search(:v2, "hashtags", query, options) do
tags_path = Endpoint.url() <> "/tag/"
- query
- |> prepare_tags(options)
+ Hashtag.search(query, options)
|> Enum.map(fn tag ->
%{name: tag, url: tags_path <> tag}
end)
end
defp resource_search(:v1, "hashtags", query, options) do
- prepare_tags(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()
+ Hashtag.search(query, options)
end
defp with_fallback(f, fallback \\ []) do
diff --git a/test/pleroma/hashtag_test.exs b/test/pleroma/hashtag_test.exs
@@ -14,4 +14,41 @@ defmodule Pleroma.HashtagTest do
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 "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
diff --git a/test/pleroma/web/mastodon_api/controllers/search_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/search_controller_test.exs
@@ -130,84 +130,66 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
assert [] = results["statuses"]
end
- test "constructs hashtags from search query", %{conn: conn} do
+ test "returns empty results when no hashtags match", %{conn: conn} do
results =
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)
- assert results["hashtags"] == [
- %{"name" => "explicit", "url" => "#{Endpoint.url()}/tag/explicit"},
- %{"name" => "hashtags", "url" => "#{Endpoint.url()}/tag/hashtags"}
- ]
+ assert results["hashtags"] == []
+ end
- results =
- conn
- |> get("/api/v2/search?#{URI.encode_query(%{q: "john doe JOHN DOE"})}")
- |> json_response_and_validate_schema(200)
+ test "supports pagination of hashtags search results", %{conn: conn} do
+ user = insert(:user)
- assert results["hashtags"] == [
- %{"name" => "john", "url" => "#{Endpoint.url()}/tag/john"},
- %{"name" => "doe", "url" => "#{Endpoint.url()}/tag/doe"},
- %{"name" => "JohnDoe", "url" => "#{Endpoint.url()}/tag/JohnDoe"}
- ]
+ {: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: "accident-prone"})}")
+ |> get("/api/v2/search?#{URI.encode_query(%{q: "a", limit: 2, offset: 1})}")
|> 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"}
- ]
+ hashtag_names = Enum.map(results["hashtags"], & &1["name"])
- results =
- conn
- |> get("/api/v2/search?#{URI.encode_query(%{q: "https://shpposter.club/users/shpuld"})}")
- |> json_response_and_validate_schema(200)
+ # 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
- assert results["hashtags"] == [
- %{"name" => "shpuld", "url" => "#{Endpoint.url()}/tag/shpuld"}
- ]
+ 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: "https://www.washingtonpost.com/sports/2020/06/10/" <> "nascar-ban-display-confederate-flag-all-events-properties/"})}"
- )
+ |> get("/api/v2/search?#{URI.encode_query(%{q: "car"})}")
|> 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
+ hashtag_names = Enum.map(results["hashtags"], & &1["name"])
- test "supports pagination of hashtags search results", %{conn: conn} do
+ # 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: "#some #text #with #hashtags", limit: 2, offset: 1})}"
- )
+ |> get("/api/v2/search?#{URI.encode_query(%{q: "race"})}")
|> json_response_and_validate_schema(200)
- assert results["hashtags"] == [
- %{"name" => "text", "url" => "#{Endpoint.url()}/tag/text"},
- %{"name" => "with", "url" => "#{Endpoint.url()}/tag/with"}
- ]
+ 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
@@ -314,7 +296,7 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do
[account | _] = results["accounts"]
assert account["id"] == to_string(user_three.id)
- assert results["hashtags"] == ["2hu"]
+ assert results["hashtags"] == []
[status] = results["statuses"]
assert status["id"] == to_string(activity.id)