Using `yield_self` for composable ActiveRecord relations

Derek Prior

Ruby 2.5 introduces Object#yield_self, which can be thought of as a close cousin to Object#tap. Where tap executes a block returning the value it’s called on, yield_self yields the object its called on into the supplied block, returning the result of the block.

Many have compared yield_self to Elixir’s pipe operator, |>, and while I use and enjoy the pipe operator in Elixir, I had a hard time envisioning how I’d use yield_self in my Ruby code. However, days after my client project was updated to Ruby 2.5, an opportunity to use yield_self just about smacked me in the face. Let’s walk through it together.

The project I’m working on makes use of several query objects. The query objects take a base relation and some parameters, ultimately producing relation that generally represnts a fairly complex SQL query. In this application, each of the query objects has the following shape:

class QueryObjectName
  def self.call(base_relation, params)
    new(base_relation, params).call
  end

  def initialize(base_relation, params)
    @base_relation = base_relation
    @params = params
  end

  def call
    # do the work
  end

  private

  attr_reader :base_relation, :params
end

The call method on many of these objects had become rather complex, with various clauses being added to the relation based on the value of different parameters. A simplified version of the call method on one such object looked like this:

def call
  patients_with_care_periods = base_relation.joins(:care_periods)

  patients_at_provider = if params.care_provider_id.present?
    patients_with_care_periods.
      where(care_periods: { care_provider_id: params.care_provider_id })
  else
    patients_with_care_periods
  end

  patients_at_provider_from_hospital = if params.hospital_id.present?
    patients_at_provider.
      where(care_periods: { hospital_id: params.hospital_id })
  else
    patients_at_provider
  end

  if params.discharge_period.present?
    patients_at_provider_from_hospital.
      joins(:hospital_visit).
      where(hospital_visits: { end_on: params.discharge_period }
  else
    patients_at_provider_from_hospital
  end
end

I found this code confusing for a number of reasons, chief among them being the local variable assignments. For example, just prior to the last if, patients_at_provider_from_hospital is the most “up to date relation” we’re working on, but that name is misleading. Depending on the value of particular parameters, the relation with that name may not say anything about the care provider or the hospital.

While extracting the code to appropriately named private methods could clean this up a bit, it would also leave a confusing string of nested method calls. Then I remembered yield_self! Rewriting the code to use my new friend made it look like this:

def call
  base_relation.
    joins(:care_periods).
    yield_self do |relation|
      if params.care_provider_id.present?
        relation.where(care_periods: { care_provider_id: params.care_provider_id })
      else
        relation
      end
    end.yield_self do |relation|
      if params.hospital_id.present?
        relation.where(care_periods: { hospital_id: params.hospital_id })
      else
        relation
      end
    end.yield_self do |reation|
      if params.discharge_period.present?
        relation.
          joins(:hospital_visit).
          where(hospital_visits: { end_on: params.discharge_period }
      else
        relation
      end
    end
end

Hmm. Well, we’ve rid ourselves of those confusing names, but this code certainly doesn’t bring me joy. To take the next step, we’re going to need the underappreciated method method in combination with & which will convert the method to a Proc.

def call
  base_relation.
    joins(:care_periods).
    yield_self(&method(:care_provider_clause)).
    yield_self(&method(:hospital_clause)).
    yield_self(&method(:discharge_period_clause))
end

private

def care_provider_clause(relation)
  if params.care_provider_id.present?
    relation.where(care_periods: { care_provider_id: params.care_provider_id })
  else
    relation
  end
end

def hospital_clause(relation)
  if params.hospital_id.present?
    relation.where(care_periods: { hospital_id: params.hospital_id })
  else
    relation
  end
end

def discharge_period_clause(relation)
  if params.discharge_period.present?
    relation.
      joins(:hospital_visit).
      where(hospital_visits: { end_on: params.discharge_period }
  else
    relation
  end
end

This code brought me joy. Marie Kondo would encourage me to keep this code. I believe this code is more readable at each step, even accounting for the possible unfamiliarity with yield_self and &method. One can reasonably expect that yield_self will become increasingly familiar to Ruby developers and with that, perhaps &method will find happy new users as well.