Search code examples
ruby-on-railsactiverecordassociationshas-one

How to define this association properly using active record?


I have the following two models in an application

class City
end

class Suburb
end

a city has exactly 5 suburbs, north, east, south, west and center. I want to reference each suburb through a correspondent method so that, north is accessed through city.north south is accessed through city.south

I can add a foreign key for each suburb in the cities table and use belongs_to and has_one to define each association. But I find it not as intuitive as it should be. This is because a Suburb belongs_to a City and the not the inverse. so the following definition is not intuitive.

class City
  belongs_to :north, class_name: 'Suburb'
  belongs_to :east, class_name: 'Suburb'
  belongs_to :south, class_name: 'Suburb'
  belongs_to :west, class_name: 'Suburb'
  belongs_to :center, class_name: 'Suburb'
end

class Suburb
  has_one :city
end

this works as expected. but when you read it, its the inverse. a Suburb belongs_to City and a City has_one :north, has_one :east, has_one :south, has_one :west and has_one :center.

I tried also to define a has_many :suburbs on the city model, and add an enum property direction to the suburb model than define a method, using define_method' for each direction, but I see it over engineered.

Is there a way to model this properly.


Solution

  • There's nothing inherently wrong with your schema, but for the sake of discussion, let me present an alternative that satisfies your modelling concerns:

    Let's have Suburb belong to City, as you propose. In order to enforce the uniqueness of suburbs with respect to their city, we add a direction column to our suburbs table, along with a unique composite index that combines city_id and direction. This way a Suburb belongs to exactly one city, and a city cannot have more than one Suburb in a given direction.

    db/migrate/...create_suburbs.rb

    class CreateDeviseUsers < ActiveRecord::Migration
      def self.change
        create_table(:suburbs) do |t|
          # no need for `index: true` here because of composite key below
          t.references :city, null: false
          t.text :direction, null: false
    
          t.index [:city_id, :direction], unique: true
        end
      end
    end
    

    app/models/city.rb

    class City < ActiveRecord::Base
      has_many :suburbs
    end
    

    Our Suburb model is now a little more complex. We need a validation for direction, and a scope for each possible value. I like to add a getter that ensures direction is always a symbol as well.

    app/models/suburb.rb

    class Suburb < ActiveRecord::Base
      DIRECTIONS = [:north, :east, :south, :west]
    
      belongs_to :city
    
      validates :city, presence: true
      validates :direction, inclusion: { in: DIRECTIONS }
    
      # define a scope for each direction
      DIRECTIONS.each { |d| scope d, -> { where(direction: d) } }
    
      # convenience getter so that we can reason about direction using symbols
      def direction
        self[:direction].try(:to_sym)
      end
    end
    

    Now we can access and drill down on cities suburbs using the scopes:

    # all north suburbs
    north_suburbs = Suburb.north
    
    # northern suburbs of a city (there can only be one!)
    north_suburbs = city.suburbs.north
    
    # as a model
    north_suburb = city.suburbs.north.first
    

    If you really don't like the first bit, you can define convenience accessors:

    app/models/city.rb

    class City < ActiveRecord::Base
      has_many :suburbs
    
      def north
        suburbs.north.first
      end
    
      # ...
    end