Skip to content

Instantly share code, notes, and snippets.

@francirp
Created August 31, 2015 18:53
Show Gist options
  • Save francirp/9280549907ff0ac77727 to your computer and use it in GitHub Desktop.
Save francirp/9280549907ff0ac77727 to your computer and use it in GitHub Desktop.
Implementing Inheritance in Ruby on Rails to DRY Up Code

Inheritance

Using inheritance in Ruby is extremely powerful and can greatly reduce complexity in the code. In this blog post, I'm going to walk through a use case for inheritance in the context of Ruby on Rails.

Example Use Case: Form Parsing

We have an apartment listings website. Users can search for apartments in their area and refine the search with filters like # of bedrooms, min and max price, and lease start date.

When a user submits the form to filter apartment listings, it would come in the controller params like so:

{
  search: { bedrooms: "3", min_price: "$500", max_price: "$800", lease_start_date: "09/01/2015" }
}

As simple as this form is, we still have some work to do to clean the data before we can query our database. For example, the following will not work right now:

Apartment.where(lease_start_date: params[:search][:lease_start_date])

Any idea why? This is because the lease_start_date column in our apartments table is a Date type. We need to convert the "09/01/2015" string to a Date object before we can perform this query.

The Quick Way Out

The quickest solution is to do something like...

# controllers/search_controller.rb

def filter_results
  @bedrooms = params[:search][:bedrooms].to_i
  @min_price = params[:search][:min_price].to_f
  @max_price = params[:search][:max_price].to_f
  @lease_start_date = Date.parse(params[:search][:lease_start_date])

  @apartments = Apartment.where(bedrooms: @bedrooms).where("price >= ?", @min_price).where("price <= ?", @max_price).where(lease_start_date: @lease_start_date)
end

This actually won't even work correctly because min_price and max_price may come in looking like "$500" instead of a clean "500", depending on what the user puts in the field ("$500".to_f turns into 0.0 ...yikes)

Further, should the filtering become more complex in the future, this controller action would get unweildy very quickly.

Service Object

Let's create an object to handle the filtering of our results.

# controllers/search_controller.rb
def filter_results
  @apartments = Forms::FilterApartments.new(params[:search]).apartments
end
# classes/forms/filter_apartments.rb

class Forms::FilterApartments

  attr_reader :bedrooms, :min_price, :max_price, :lease_start_date

  def initialize(args = {})
    @bedroooms = args[:bedrooms].to_i
    @min_price = string_to_float(args[:min_price])
    @max_price = string_to_float(args[:max_price])
    @lease_start_date = string_to_date(args[:lease_start_date])
  end

  def apartments
    @apartments = Apartment.where(bedrooms: bedrooms)
    @apartments = @apartments.where("price >= ?", min_price)
    @apartments = @apartments.where("price <= ?", max_price)
    @apartments = @apartments.where(lease_start_date: lease_start_date)
  end

  private

    def string_to_float(currency_string)
      float_regex = /(\d|[.])/
      currency_string.scan(float_regex).join.try(:to_f)
    end

    def string_to_date(date_string)
      Date.parse(date_string)
    end

end

Note that this service object even accounts for the situation where min_price and max_price comes in as $500 instead of 500.

This service object is certainly an enhancement. It provides a place where we can perform more advanced parsing and logic and separates the responsibility of the results filtering into one object rather than a controller action that is sure to become too unweildy to manage over time as new filters are added.

Yikes - New Feature Request

Our app has taken off and users want another page to see house listings. Let's start to write the controller action and corresponding service object to filter results for houses. Unlike apartments, houses come with some additional filtering options: :acreage, and :has_front_porch

# controllers/search_controller.rb

def filter_houses
  @houses = Forms::FilterHouses.new(params[:search]).houses
end
# classes/forms/filter_houses.rb

class Forms::FilterHouses

  attr_reader :bedrooms, :min_price, :max_price, :lease_start_date, :acreage, :has_front_porch

  def initialize(args = {})
    @bedroooms = args[:bedrooms].to_i
    @min_price = string_to_float(args[:min_price])
    @max_price = string_to_float(args[:max_price])
    @lease_start_date = string_to_date(args[:lease_start_date])
    @acreage = string_to_float(args[:acreage])
    @has_front_porch = string_to_boolean(args[:has_front_porch])
  end

  def houses
    @houses = House.where(bedrooms: bedrooms)
    @houses = @houses.where("price >= ?", min_price)
    @houses = @houses.where("price <= ?", max_price)
    @houses = @houses.where(lease_start_date: lease_start_date)
    @houses = @houses.where("acreage >= ?", acreage)
    @houses = @houses.where(has_front_porch: has_front_porch)
  end

  private

    def string_to_float(currency_string)
      float_regex = /(\d|[.])/
      currency_string.scan(float_regex).join.try(:to_f)
    end

    def string_to_date(date_string)
      Date.parse(date_string)
    end

    def string_to_boolean(boolean_string)
      boolean_string == "true"
    end

end

Man, the Houses service object seems very similar to the Apartments service object. Both have a method for converting strings to floats. Both are converting the incoming lease_start_date string to a date as well. Further, both are filtering by a lot of the same values. Intuitively, we know that searching for houses and searching for apartments are fundamentally similar experiences. So it is no surprise to me we are seeing these commonalities in the code.

How can we dry this up? Let's give inheritance a shot.

Implementing Inheritance

# classes/forms/property_filter.rb

class Forms::PropertyFilter

  # this object will be used by both the
  # apartments and housing filter objects

  attr_reader :bedrooms, :min_price, :max_price, :lease_start_date

  def initialize(args = {})
    @bedroooms = args[:bedrooms].to_i
    @min_price = string_to_float(args[:min_price])
    @max_price = string_to_float(args[:max_price])
    @lease_start_date = string_to_date(args[:lease_start_date])
    after_initialize(args) # implemented by houses and apartments filters
  end

  def results
    @results = model.where(bedrooms: bedrooms)
    @results = @results.where("price >= ?", min_price)
    @results = @results.where("price <= ?", max_price)
    @results = @results.where(lease_start_date: lease_start_date)
  end

  private

    def after_initialize(args = {})
      # implemented by subclasses
    end

    def model
      # required method for subclasses
      # need to know what table to find the data
      raise "#{self.class.name} must implement model method."
      # i.e. "Forms::Filter::Apartments must implement model method"
    end

    def string_to_float(currency_string)
      float_regex = /(\d|[.])/
      currency_string.scan(float_regex).join.try(:to_f)
    end

    def string_to_date(date_string)
      Date.parse(date_string)
    end

end
# classes/forms/property_filter/apartments.rb

class Forms::PropertyFilter::Apartments < Forms::PropertyFilter

  def results
    super # runs results method in Forms::PropertyFilter
  end

  private

    def model
      Apartment
    end

end
# classes/forms/property_filter/houses.rb

class Forms::PropertyFilter::Houses < Forms::PropertyFilter

  attr_reader :acreage, :has_front_porch

  def results
    super # runs results method in Forms::PropertyFilter
    @results = @results.where("acreage >= ?", acreage)
    @results = @results.where(has_front_porch: has_front_porch)
  end

  private

    def after_initialize(args = {})
      @acreage = string_to_float(args[:acreage])
      @has_front_porch = string_to_boolean(args[:acreage])
    end

    def model
      House
    end

    def string_to_boolean(boolean_string)
      boolean_string == "true"
    end

end

With these three classes in place, we have abstracted the commonalities between FilterApartments and FilterHouses into one class, PropertyFilter. Further, since the Houses filter has every filter that Apartments has plus acreage and has_front_porch, we see that the Houses filter only needs to worry about acreage and has_front porch.

In my opinion, implementing inheritance in this situation is absolutely critical. What happens, for example, if the bedrooms filter changes? Maybe users want to see apartments and houses with more than 3 bedrooms instead of filtering exactly on the bedroom number. This type of change would be cumbersome if the logic for filtering bedrooms was in both the Apartments filtering class and the Houses filtering class (let alone separate controller actions if we didn't implement service objects).

Actually, if I was implementing a website with this functionality, I would use Single Table Inheritance:

# models/apartment.rb
def Apartment < Property
end

# models/house.rb
class House < Property
end

def Property < ActiveRecord::Base
end

With this modeling, we would not need the "model" method in our PropertyFilter service object. To me, a house and an apartment are too similar of entities to not inherit from a common model, which intuitively is a property. Further, I can envision a situation in which both apartments and houses are shown as results to users in the same list.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment