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?