Two Tables Become Three
kevhines
Posted on April 3, 2021
Had my first experience with a model that needed a self-join.
A self-join, or self-referential association, is when a model has a relationship with itself. For example maybe an HR app has an Employee model that needs to associate with itself to let you know who manage's who. Or a Social Media app has a User model that needs to associate who is friend/follower with who.
In my case I have a Monster model and I need to know which Monster has fought which other Monster.
We're talking Godzilla vs King Kong - The Ultimate Ruby on Rails experience!!
To understand how this will work let's first pretend that I am building a Monster vs Baseball_Player app.
In that case I'd have these three models with the following associations:
class Monster < ApplicationRecord
has_many :fights
has_many :baseball_players, through: :fights
end
class BaseballPlayers < ApplicationRecord
has_many :fights
has_many :monsters, through: :fights
end
class Fight < ApplicationRecord
belongs_to :monster
belongs_to :baseball_player
end
If you've done typical many to many associations this should all make sense. So when Godzilla steps on Mike Trout and then steps on Clayton Kershaw you'd have this:
godzilla = Monster.create(name: "Godzilla")
trout = BaseballPlayer.create(name: "Mike Trout")
kershaw = BaseballPlayer.create(name: "Clayton Kershaw")
fight1 = Fight.create(monster: godzilla, baseball_player: trout)
fight2 = Fight.create(monster: godzilla, baseball_player: kershaw)
godzilla.baseball_players = [trout, kershaw]
trout.monsters = [godzilla]
kershaw.monsters = [godzilla]
But we don't want our monsters fighting baseball players. It's not a fair fight! So lets just swap in monsters for baseball players and see what issues it causes. First thing we'd notice is we have two monsters, so to identify them differently in the fight model we need to call them by different names, like monster1 and monster2, instead of just monster. In fact to make it clearer lets have it be challenger and defender. So we want this to work:
godzilla = Monster.create(name: "Godzilla")
kingkong= Monster.create(name: "King Kong")
mothra= Monster.create(name: "Mothra")
fight1 = Fight.create(challenger: godzilla, defender: kingkong)
fight2 = Fight.create(challenger: godzilla, defender: mothra)
godzilla.defenders = [kingkong, mothra]
trout.challengers = [godzilla]
kershaw.challengers = [godzilla]
So our first stab might be to set up associations like this (spoiler alert: this won't work):
class Monster < ApplicationRecord
has_many :fights
has_many :defenders, through: :fights
has_many :fights
has_many :challengers, through: :fights
end
class Fight < ApplicationRecord
belongs_to :monster
belongs_to :monster
end
Oops! Our Fight model has 'belongs_to :monster' twice? That doesn't make sense.
Remember we called them challengers and defenders instead of using monster twice, so let's fix that.
class Fight < ApplicationRecord
belongs_to :challenger, class_name: "Monster"
belongs_to :defender, class_name: "Monster"
end
We changed monsters to the names that helped differeniate them, but then we had to tell the model what the true class name is (in this case it's Monster) so it can find the appropriate table.
So now we need to clear up our Monster class. You can't just say has_many :fights because ActiveRecord is going to look for monster_id in the fights table and that foreign key doesn't exist. So let's tell the Monster model what to look for.
(another spoiler: not quite working yet)
class Monster < ApplicationRecord
has_many :fights, foreign_key: :challenger_id, class_name: "Fight"
has_many :defenders, through: :fights
has_many :fights, foreign_key: :defender_id, class_name: "Fight"
has_many :challengers, through: :fights
end
I told the model to use the foreign_key challenger_id and defender_id respectively so it knew where to look. But now the has_many, through: relationship has hit a snag. Which foreign key are we using when we say through: :fights ? It's confusing! We need to distinguish our two fights relationships with unique names too. That works just it did in our Fights model
class Monster < ApplicationRecord
has_many :fights_challenged, foreign_key: :challenger_id, class_name: "Fight"
has_many :defenders, through: :fights_challenged
has_many :fights_defended, foreign_key: :defender_id, class_name: "Fight"
has_many :challengers, through: :fights_defended
end
Phew. That should work. Here's the final product all put together:
class Monster < ApplicationRecord
has_many :fights_challenged, foreign_key: :challenger_id, class_name: "Fight"
has_many :defenders, through: :fights_challenged
has_many :fights_defended, foreign_key: :defender_id, class_name: "Fight"
has_many :challengers, through: :fights_defended
end
class Fight < ApplicationRecord
belongs_to :challenger, class_name: "Monster"
belongs_to :defender, class_name: "Monster"
end
Now I can set up Godzilla and King Kong to fight. They can't wait to take advantage of this self-join!!
Posted on April 3, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.