Search code examples
ruby-on-railssingle-table-inheritance

Avoiding STI in Rails


class User < ActiveRecord::Base
  has_one :location, :dependent => :destroy, :as => :locatable
  has_one :ideal_location, :dependent => :destroy, :as => :locatable
  has_one :birthplace, :dependent => :destroy, :as => :locatable
end

class Location < ActiveRecord::Base
  belongs_to :locatable, :polymorphic => true
end

class IdealLocation < ActiveRecord::Base
end

class Birthplace < ActiveRecord::Base
end

I can't really see any reason to have subclasses in this situation. The behavior of the location objects are identical, the only point of them is to make the associations easy. I also would prefer to store the data as an int and not a string as it will allow the database indexes to be smaller.

I envision something like the following, but I can't complete the thought:

class User < ActiveRecord::Base
  LOCATION_TYPES = { :location => 1, :ideal_location => 2, :birthplace => 3 }

  has_one :location, :conditions => ["type = ?", LOCATION_TYPES[:location]], :dependent => :destroy, :as => :locatable
  has_one :ideal_location, :conditions => ["type = ?", LOCATION_TYPES[:ideal_location]], :dependent => :destroy, :as => :locatable
  has_one :birthplace, :conditions => ["type = ?", LOCATION_TYPES[:birthplace]], :dependent => :destroy, :as => :locatable
end

class Location < ActiveRecord::Base
  belongs_to :locatable, :polymorphic => true
end

With this code the following fails, basically making it useless:

user = User.first
location = user.build_location
location.city = "Cincinnati"
location.state = "Ohio"
location.save!

location.type # => nil

This is obvious because there is no way to translate the :conditions options on the has_one declaration into the type equaling 1.

I could embed the id in the view anywhere these fields appear, but this seems wrong too:

<%= f.hidden_field :type, LOCATION_TYPES[:location] %>

Is there any way to avoid the extra subclasses or make the LOCATION_TYPES approach work?

In our particular case the application is very location aware and objects can have many different types of locations. Am I just being weird not wanting all those subclasses?

Any suggestions you have are appreciated, tell me I'm crazy if you want, but would you want to see 10+ different location models floating around app/models?


Solution

  • As far as I can see it, a Location is a location is a Location. The different "subclasses" you're referring to (IdealLocation, Birthplace) seem to just be describing the location's relationship to a particular User. Stop me if I've got that part wrong.

    Knowing that, I can see two solutions to this.

    The first is to treat locations as value objects rather than entities. (For more on the terms: Value vs Entity objects (Domain Driven Design)). In the example above, you seem to be setting the location to "Cincinnati, OH", rather than finding a "Cincinnati, OH" object from the database. In that case, if many different users existed in Cincinnati, you'd have just as many identical "Cincinnati, OH" locations in your database, though there's only one Cincinnati, OH. To me, that's a clear sign that you're working with a value object, not an entity.

    How would this solution look? Likely using a simple Location object like this:

    class Location
      attr_accessor :city, :state
    
      def initialize(options={})
        @city = options[:city]
        @state = options[:state]
      end
    end
    
    class User < ActiveRecord::Base
      serialize :location
      serialize :ideal_location
      serialize :birthplace
    end
    
    @user.ideal_location = Location.new(:city => "Cincinnati", :state => "OH")
    @user.birthplace = Location.new(:city => "Detroit", :state => "MI")
    @user.save!
    
    @user.ideal_location.state # => "OH"
    

    The other solution I can see is to use your existing Location ActiveRecord model, but simply use the relationship with User to define the relationship "type", like so:

    class User < ActiveRecord::Base
      belongs_to :location, :dependent => :destroy
      belongs_to :ideal_location, :class_name => "Location", :dependent => :destroy
      belongs_to :birthplace, :class_name => "Location", :dependent => :destroy
    end
    
    class Location < ActiveRecord::Base
    end
    

    All you'd need to do to make this work is include location_id, ideal_location_id, and birthplace_id attributes in your User model.