Using has_many :through for Nested Relations in Rails

2 minute read

has_many :through is a useful association type of Rails. It’s mostly popular and often used as a join model for many-to-many relations.

However, has_many :through is more than a simple join model, because it conducts INNER JOIN(s) on related models. We can also take the advantage of this behaviour on nested has_many relations. Lets imagine a scenario where we have a nested has_many structure as follows:

Country (has_many :regions)
  -> Region (has_many :cities)
      -> City (has_many :districts)
          -> District

Models and tables of the structure:

class Country < ApplicationRecord
  has_many :regions
end

|  Country  |
|  :-----   |
|  name     |
class Region < ApplicationRecord
  belongs_to :country
  has_many :cities
end

|  Region     |
|  :-----     |
|  name       |
|  country_id |
class City < ApplicationRecord
  belongs_to :region
  has_many :districts
end

|  City      |
|  :-----    |
|  name      |
|  region_id |
class District < ApplicationRecord
  belongs_to :city
end

|  District |
|  :-----   |
|  name     |
|  city_id  |

In this case, it’s quite hard to reach records after more than one level of association. It’s very possible to struggle when reaching districts of a country and you will probably end up with complicated queries. First you have to scan all regions of a specific country, then you have to scan cities of all that regions, and finally you will end up with districts of the cities, and so on. With the current structure, we are unable to run simple queries like Country.first.districts, firstly because we don’t have a country_id in our District model, and secondly there is no relationship between Country and District

Here where has_many :through comes in to the action! Lets update our models with has_many :through without modifying the tables:

class Country < ApplicationRecord
  has_many :regions
    has_many :cities, through: :regions
    has_many :districts, through: :cities
end
class Region < ApplicationRecord
  belongs_to :country
  has_many :cities
  has_many :districts, through: :cities
end
class City < ApplicationRecord
  belongs_to :region
  has_many :districts
end
class District < ApplicationRecord
  belongs_to :city
end

And magic happens 🎉 Notice that, we didn’t add any foreign_key to our models! Just plain old Rails associations. Now we are able to run any query between these models, such as:

Country.first.districts
Region.first.districts

The opposite of this relation is also quite easy. We don’t have a belongs_to :through simply because we don’t need to. You can reach the country of a district as follows:

District.first.city.region.country

But how does Rails understand a relation between district and country since we don’t have foreign_keys? Simple, as I mentioned before - it conducts INNER JOIN(s). Lets look at the SQL query run:

country = Country.find_by(name: 'Turkey')
country.districts.to_sql

=>  "SELECT "districts".* FROM "districts" INNER JOIN "cities" ON "districts"."city_id" = "cities"."id" INNER JOIN "regions" ON "cities"."region_id" = "regions"."id" WHERE "regions"."country_id" = 209"

Cheers.