ruby 72 lines · 3 tabs

Filtering a Listing by Tags with a has_many :through Scope and a Query Object

Shared by codesnips Aug 2026
3 tabs
class Article < ApplicationRecord
  has_many :taggings, dependent: :destroy
  has_many :tags, through: :taggings

  scope :published, -> { where.not(published_at: nil) }

  scope :with_any_tag, ->(slugs) do
    joins(:tags).where(tags: { slug: slugs }).distinct
  end

  scope :with_all_tags, ->(slugs) do
    slugs = Array(slugs).uniq
    joins(:tags)
      .where(tags: { slug: slugs })
      .group("articles.id")
      .having("COUNT(DISTINCT tags.id) = ?", slugs.size)
  end

  scope :recent_first, -> { order(published_at: :desc) }
end
3 files · ruby Explain with highlit

This snippet shows how a tag-based filter for a listing page is built cleanly in Rails by pushing the join logic into ActiveRecord scopes and the request-level orchestration into a dedicated query object. The problem it solves is common: an Article has many Tag records through a join table, and the index needs to filter by one or more tag slugs without leaking raw SQL, N+1 queries, or messy conditionals into the controller.

In Article model, the association is declared with has_many :taggings and has_many :tags, through: :taggings, which is the standard way to model a many-to-many relationship with an explicit join model. The with_all_tags scope is the interesting part: filtering on a has_many :through for all of several tags cannot be done with a single where(tags: { slug: slugs }), because that matches rows having any of the slugs. Instead it joins the join table, restricts to the wanted slugs, groups by articles.id, and uses HAVING COUNT(DISTINCT ...) = ? so only articles carrying every requested tag survive. The with_any_tag scope shows the simpler OR-semantics variant. distinct and includes(:tags) guard against duplicate rows and N+1 loading.

In ArticleFilterQuery, the query object encapsulates how request parameters become a relation. It starts from an injectable relation (defaulting to Article.published), then conditionally chains scopes based on params. Keeping this in a plain Ruby object means the controller stays thin, the logic is unit-testable without HTTP, and the filtering rules live in one place. The match param toggles between with_all_tags and with_any_tag, and normalized_slugs defends against blank or duplicate input.

In ArticlesController, the index action simply instantiates the query with params and paginates the result. The controller never touches the join or the SQL; it only names intent. This separation is worth reaching for once filtering grows beyond a single where, because scopes stay composable and reusable while the query object owns the branching. The main trade-offs are the GROUP BY/HAVING cost on large tables (an index on taggings(tag_id, article_id) helps) and the need to keep distinct in mind whenever joining a one-to-many.


Related snips

Share this code

Here's the card — post it anywhere.

Filtering a Listing by Tags with a has_many :through Scope and a Query Object — share card
Link copied