ActiveRecord Finder Method Frustration
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
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
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
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
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
#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
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">]
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
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
User's many-to-many association to a more explicit
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_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! ;)