Skip to content

Instantly share code, notes, and snippets.

@francirp
Created August 31, 2015 18:53
Show Gist options
  • Save francirp/2dbe6daf7faf214dd67e to your computer and use it in GitHub Desktop.
Save francirp/2dbe6daf7faf214dd67e 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:
```ruby
{
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:
```ruby
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...
```ruby
# 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.
```ruby
# controllers/search_controller.rb
def filter_results
@apartments = Forms::FilterApartments.new(params[:search]).apartments
end
```
```ruby
# 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
```ruby
# controllers/search_controller.rb
def filter_houses
@houses = Forms::FilterHouses.new(params[:search]).houses
end
```
```ruby
# 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
---
```ruby
# 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
```
```ruby
# 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
```
```ruby
# 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:
```ruby
# 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