2
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-03-04 01:36:36 +08:00
discourse/app/services/category/hierarchical_search.rb
Loïc Guitaut bcf33a2901
DEV: Refactor category hierarchical search (#37609)
The monolithic `CategoryHierarchicalSearch` service mixed query
building, eager loading, and pagination logic in a single class. The
query was a large raw SQL string built through conditional string
concatenation. Fragments like `#{matches_sql}`, `#{only_ids_sql}`,
`#{except_ids_sql}` were stitched together, with LIMIT/OFFSET appended
via ternary interpolation and named placeholders passed through a
manually assembled hash. This made the query fragile and hard to follow.

Break it into focused, single-responsibility classes under the
`Category::` namespace:

- `Category::HierarchicalSearch` — service orchestrator using
`Service::Base`, with a contract that owns pagination logic (page
validation, limit/offset computation)
- `Category::Query::HierarchicalSearch` — query object that uses
ActiveRecord's interface where it naturally fits (`.where()` with
parameter binding, `.limit()`, `.offset()`, `.with()`, `.joins()`) and
isolates the genuinely complex SQL (recursive CTEs, term matching) into
small named methods rather than a monolithic heredoc
- `Category::Action::EagerLoadAssociations` — extracted eager loading
into a reusable `Service::ActionBase`

The controller is simplified to a single
`Category::HierarchicalSearch.call(service_params)` call with proper
`on_success` / `on_failed_contract` / `on_failure` handling, replacing
manual param transformation and direct result access.

Specs are rewritten to test each class in isolation: the service spec
stubs its collaborators to verify orchestration, the query spec
exercises actual SQL behavior, and the action spec verifies preloading.

Service structure and spec patterns follow the [Discourse service object
guidelines](https://meta.discourse.org/t/using-service-objects-in-discourse/333641)
and the [RSpec Style Guide](https://rspec.rubystyle.guide/).
2026-02-13 09:43:56 +01:00

32 lines
862 B
Ruby

# frozen_string_literal: true
class Category::HierarchicalSearch
include Service::Base
params do
attribute :term, :string, default: ""
attribute :only, :array, default: [], compact_blank: true
attribute :except, :array, default: [], compact_blank: true
attribute :page, :integer, default: 1
validates :page, numericality: { greater_than: 0 }
after_validation { self.term = term.to_s.strip }
def limit = CategoriesController::MAX_CATEGORIES_LIMIT
def offset = (page - 1) * limit
end
model :categories, optional: true
step :eager_load_associations
private
def fetch_categories(guardian:, params:)
Category::Query::HierarchicalSearch.new(guardian:, params:).call
end
def eager_load_associations(guardian:, categories:)
Category::Action::EagerLoadAssociations.call(categories:, guardian:)
end
end