Active­Record Finder Method Frus­tration

For my first web app project, I decided to try porting something from an older Ruby lesson (a command line Tic-Tac-Toe game with single-player and two-player modes) to run on the Sinatra framework.

I didn't anticipate that a recurring (and the most befuddling) speed bump would be something which I thought I had figured out in the first five minutes of coding.


My app would have a Game and User model. Since both would "have" multiple of the other, I set up a quick "many-to-many" relationship using a join table (I also gave the Game#users accessor an alias of Game#players, for readability):

class Game < ActiveRecord::Base
  has_many :game_user_relationships
  has_many :users, through: :game_user_relationships
  alias_attribute :players, :users
end

class User < ActiveRecord::Base
  has_many :game_user_relationships
  has_many :games, through: :game_user_relationships
end

class GameUserRelationship < ActiveRecord::Base
  belongs_to :game
  belongs_to :user
end

Next, I wanted to be able to grab the players from a game instance using Game#player_1 and Game#player_2:

class Game < ActiveRecord::Base

  # ...

  def player_1
    @player_1 ||= players.first
  end

  def player_2
    @player_2 ||= players.last
  end

end

I figured that this way, even if I didn't have explicit "Player 1" and "Player 2" slots, I could easily control the assignment of player positions by controlling the order in which they were added to the Game#players collection.

Given the following input…

game = Game.new
game.players << user_2
game.players << user_1
game.save

I thought I could expect this result:

game.player_1 #=> user_2
game.player_2 #=> user_1

Things proceeded without a hitch until hours later when I realized I was actually (some if not all of the time) getting this result:

game.player_1 #=> user_1
game.player_2 #=> user_2

Rather than the order in which they were added to the game, it seemed they were being sorted by their primary key on the :users table.

My understanding was that calling game.players should give me an array of User instances in the order in which they occurred on the :game_player_relationships join table. So, I thought, calling game.players.first should give me the first element of that array.

It turns out, though, that ActiveRecord has its own special SQL-powered #first and #last methods for its AssociationProxy sub-classes (the array-like objects that are returned by ActiveRecord database queries). These methods can take a sort order as an argument, but default to sorting by :id (in this case, the primary key on the :users table).

As an example of how easily this can sneak up on you, take a look at this series of calls:

Game.first.players
#=> [#<Player:0x00000002f6f188 id: 2, name: "user_2">,
     #<Player:0x00000002f6eff8 id: 1, name: "user_1">]

Notice that user_2 is the first element in the array.

Game.first.players.first
#=> #<Player:0x00000002ef0400 id: 1, name: "user_1">

As we've come to expect, calling #players.first grabs the first user by id, not its order in the collection.

However, if we store Game.first in a local variable, things start to behave differently:

game = Game.first
#=> #<Game:0x000000032f4e70 id: 1>

game.players.first
#=> #<Player:0x000000032b6530 id: 1, name: "user_1">

game.players
#=> [#<Player:0x00000003281fb0 id: 2, name: "user_2">,
     #<Player:0x00000003281e70 id: 1, name: "user_1">]

game.players.first
#=> #<Player:0x00000003281fb0 id: 2, name: "user_2">

Notice that the first and second calls to #players.first gave opposite results.

Using Tux, we can see that the second call does not fire its own SQL query, but relies on the return value of the previous call to game.players and treats it as a normal Array:

game.players.first
# Player Load (0.1ms)  SQL QUERY
#
#=> #<Player id: 1, name: "user_1">

game.players
# Player Load (0.1ms)  SQL QUERY
#
#=> #<ActiveRecord::Associations::CollectionProxy [
    #<Player id: 2, name: "user_2">,
    #<Player id: 1, name: "user_1">]>

game.players.first
#=> #<Player id: 2, name: "user_2">

If local variables aren't involved, the return values don't seem to fall for the same trick:

Game.first.players.first
#=> #<Player:0x00000004291a40 id: 1, name: "user1">

Game.first.players
#=> [#<Player:0x00000004231fa0 id: 2, name: "user2">,
     #<Player:0x00000004231e60 id: 1, name: "user1">]

Game.first.players.first
#=> #<Player:0x000000041b85d8 id: 1, name: "user1">

At the end of the day (before I learned what had been going on under the hood), I "solved" my problem by changing Game and User's many-to-many association to a more explicit belongs_to/has_many association.

I get the feeling it was a needlessly verbose approach, but I was in too much of a hurry to learn yet-fancier ways to declare ActiveRecord associations:

class Game < ActiveRecord::Base

  belongs_to :player_1, class_name: 'User'

  belongs_to :player_2, class_name: 'User'

end



class User < ActiveRecord::Base

  has_many :games_as_player_1, class_name: 'Game',
    foreign_key: :player_1_id, inverse_of: :player_1

  has_many :games_as_player_2, class_name: 'Game',
    foreign_key: :player_2_id, inverse_of: :player_2

  def games
    @games ||= games_as_player_1 + games_as_player_2
  end

end

After adding a User#games helper method to return the whole list of games, my code worked without any other changes.

In retrospect, though, I could have solved the situation just as well (and much more easily) by calling a more explicit query within the original #player_1 and #player_2 helper methods.

Something like this:

class Game < ActiveRecord::Base

  has_many :game_user_relationships
  has_many :users, through: :game_user_relationships
  alias_attribute :players, :users

  def player_1
    @player_1 ||= players(
      order: "game_player_relationships.id"
      ).first
  end

  def player_2
    @player_2 ||= players(
      order: "game_player_relationships.id"
      ).last
  end

end

Then again, I'd never touched either SQL or ActiveRecord (or anything except plain old Ruby!) before a couple weeks ago, so I doubt that either solution (or the whole app) couldn't be trimmed down substantially, redundant database queries and all. It's all up on GitHub, so feel free to check it out and tell me what else I've screwed up! ;)

Located in Dallas, TX and looking for full-time employment as a web developer.