.merge: reusing a scope from the joined model

Free preview 3 min to read

What "JOINs in Rails" already covers

The lesson "JOINs in Rails: ActiveRecord associations", in SQL: JOIN fundamentals, is thorough. It covers joins (an INNER JOIN that filters and doesn't load), left_joins (a LEFT OUTER JOIN, including anti-joins), and the three loading strategies includes, eager_load and preload. It walks through the N+1 problem end to end, including the nested includes(lessons: :challenge) form, the key difference between joins and includes, a decision matrix and the bullet gem. This course doesn't repeat any of it. It starts from .merge, which that lesson never mentions, and goes into ground that ActiveRecord associations: has_one/through and ActiveRecord associations: polymorphic set up but never queried.

Filtering on a joined table, two ways

Catalog::Course has an active scope: scope :active, -> { where(active: true) }. Finding lessons that belong to an active course can be written as a .where on the joined table:

Catalog::Lesson.joins(:course).where(courses: { active: true })

Or it can reuse the scope that already exists on Catalog::Course, instead of typing its condition again:

Catalog::Lesson.joins(:course).merge(::Catalog::Course.active)

The .to_sql of the two is identical: SELECT "lessons".* FROM "lessons" INNER JOIN "courses" ON "courses"."id" = "lessons"."course_id" WHERE "courses"."active" = TRUE. .merge takes the WHERE conditions (and other query parts) from the relation you pass it, Catalog::Course.active, and folds them into the calling relation, as if you had written the .where by hand.

This line already runs in this app

# app/queries/catalog/search_query.rb
lessons = ::Catalog::Lesson.joins(:course).merge(::Catalog::Course.active)
  .includes(:course)
  .where(title_match(:lessons, locale), pattern)
  .order(:course_id, :position).limit(limit).to_a

This is the catalog search, SearchQuery. It finds lessons whose title matches a search term, but only in courses that are active. .joins(:course) brings in the courses table for filtering, which "JOINs in Rails" already covers. .merge(::Catalog::Course.active) reuses the active scope of Course instead of repeating where(active: true) inline. If the meaning of "active" changed, say a second condition added to the scope, every .merge(Course.active) in the codebase would pick it up. A hand-written .where(courses: { active: true }) spread across several query objects would need each copy updated by hand.

Why this is a different mechanic from joins/includes

joins, left_joins, includes, eager_load and preload all answer "how is this association loaded, and does it filter?" .merge answers a different question: "how do I reuse a scope already defined on the model I joined, instead of rewriting its logic inline?" It works with any of the five loading strategies. .merge doesn't care whether the association came in through joins or includes; it takes conditions from one relation and applies them to another.

Key takeaways

  • "JOINs in Rails: ActiveRecord associations" covers joins, left_joins, includes, eager_load, preload and the N+1 problem in full. This course starts past that, with .merge.
  • Lesson.joins(:course).merge(Course.active) generates the same SQL as Lesson.joins(:course).where(courses: { active: true }): .merge folds the other relation's WHERE conditions into the calling one.
  • This app's Catalog::SearchQuery uses joins(:course).merge(Course.active), reusing the active scope of Course instead of repeating where(active: true), so "active" is defined in one place.
  • .merge answers "reuse a scope from the joined model", a different question from the one joins, includes, eager_load and preload answer about loading, and it works with any of them.
Lesson 1 of 7 · free

Keep going with lesson 2

The joins + .count trap: duplicate rows from the JOIN

Why joins(:lessons).count counts course-lesson pairs, how .distinct.count fixes it, and why includes(:lessons).count skips the JOIN.

A free account also gives you the quizzes, your progress and notes, and the discussion.

Type to start searching...