Fun with Rails has_one through association

In my Rails developer career I had never felt the need to use the has_one through: association. Lately while working on a cool project I found out how useful and convenient this kind of association can be.

Let’s consider a simple use case. We have three ActiveRecord models: Player, Team and the join model Membership. A team has many players and a player can have many teams through its memberships. Of course a team has a captain, but this is something I will show later. Let’s start from the basic associations and the migration to create the tables:


class Player < ActiveRecord::Base
  has_many :memberships
  has_many :teams, through: :memberships
end

class Team < ActiveRecord::Base
  has_many :memberships
  has_many :players, through: :memberships
end

class Membership < ActiveRecord::Base
  belongs_to :team
  belongs_to :player
end

def change
  create_table :players do |t|
    t.string :name
  end

  create_table :teams do |t|
    t.string :name
  end

  create_table :memberships do |t|
    t.references :player, index: true
    t.references :team, index: true
    t.boolean :captain
  end
end

Alright, nothing really fancy here. When we want to associate a player to a particular team we don’t need to explicitly create the team membership record because rails can handle the underlying db details:


Membership.count
# => 0
player = Player.create name: 'Racco'
team = Team.create name: 'Mikamai'
team.players << player
Membership.count
# => 1

Let’s add another player to the team:


captain = Player.create name: 'Intini'
team.players << captain

That’s cool indeed. We how have two players in our team. You can tell by the variable name that we will soon make a captain of the last one that we created. Let’s complete the Team model in order to make the captain feature work:


class Team < ActiveRecord::Base
  has_many :memberships
  has_many :players, through: :memberships

  has_one :captain_membership, -> { where captain: true}, class_name: 'Membership'
  has_one :captain, through: :captain_membership, source: :player
end

Let’s put the new code to good use:


team.captain = captain

And that’s it! Rails will handle all the association details as before, but this time setting the membership captain boolean value to true as well.

Are we done? No, there’s a problem. Let’s see how many memberships we have now in the database:


 team.memberships.count
 # => 3

What about players?


team.players.count
# => 3

Ouch. We created only 2 players, so something went wrong.


team.players.map &:name
# => ["Racco", "Intini", "Intini"]

team.memberships.map { |membership| membership.player.name }
# => ["Racco", "Intini", "Intini"]

When we created the captain relation a new membership record was created as well, this time with the captain attribute set to true. One way to fix the players relationship is to pick players only once, making them unique, and this is quite easy to accomplish, we just need to update the team players relationship:


class Team < ActiveRecord::Base
  has_many :players, -> { uniq }, through: :memberships
end

Let’s try again with the new code:


team = Team.find_by_name 'mikamai'
team.players.count
# => 2

Success! Now, what happens if we change the captain? The existing captain membership record gets updated with a new player_id:


team.captain_membership 
# =>Membership id: 7, player_id: 6, team_id: 1, owner: true ...>

racco = Player.find_by_name 'racco'
team.captain = racco

team.captain_membership.reload
# =>Membership id: 7, player_id: 7, team_id: 1, owner: true ...> 

One last check then we’re done: what happens if the captain decides to leave the team? Will both relevant membership records be destroyed?


captain = team.captain
team.players.delete captain

team.players.count
# => 1
team.memberships.count
# => 1
team.captain 
# => nil

So everything works smoothly, no need to manually clear out stale association records. Did I mention that I love ActiveRecord?

Leave a Reply

Please Login to comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.