Two Tables Become Three

kevhines

kevhines

Posted on April 3, 2021

Two Tables Become Three

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
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Now I can set up Godzilla and King Kong to fight. They can't wait to take advantage of this self-join!!

💖 💪 🙅 🚩
kevhines
kevhines

Posted on April 3, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related