Use inverse_of
Skip the Rails magic and set the :inverse_of option on your ActiveRecord associations
Let's talk about :inverse_of
.
We know Rails has ActiveRecord and ActiveRecord gives us associations and associations can really simplify our interactions with databases. These associations provide a number of configuration options, one of which is to set the "inverse of" your current relation.
This option name can be a little confusing at first so let's use an example. Let's say we have an
Author
class and it has_many :posts
. This means we should have a Post
class that maintains a
column, :author_id
, so it we can say it belongs_to :author
.
# app/models/author.rb
class Author < ActiveRecord::Base
has_many :posts
end
# app/models/post.rb
class Post < ActiveRecord::Base
belongs_to :author
end
Ok, so we know this means if we have an author, we can ask for her posts.
# Loading development environment (Rails 4.2.5)
author = Author.find(1)
# Author Load (0.3ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = $1 LIMIT 1 [["id", 1]]
#=> #<Author:0x007fde81898868 id: 1, ... >
author.posts
# Post Load (0.4ms) SELECT "posts".* FROM "posts" WHERE "posts"."author_id" = $1 [["author_id", 1]]
#=> [#<Post:0x007fde810cb4a0 id: 1, ... >, #<Post:0x007fde810cb248 id: 2, ... >, ... ]
We can also query for a post and ask for its author.
post = Post.find(1)
# Post Load (0.3ms) SELECT "posts".* FROM "posts" WHERE "posts"."id" = $1 LIMIT 1 [["id", 1]]
#=> #<Post:0x007fde81c7d730 id: 1, ... >
post.author
# Author Load (0.3ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = $1 LIMIT 1 [["id", 1]]
#=> #<Author:0x007fde7a5c8518 id: 1, ... >
What It's For
Now, for most of our associations, Rails helps us find the inverse relation. For example, if we
start with an author, then ask for her posts, each post will "know" that the inverse instance of this
relationship is the author. If we iterate over each of author.posts
and ask each post for its
author, we expect to get the same author record:
author.posts.map { |post| post.author }
# => [#<Author:0x007fde81898868 id: 1, ... >, #<Author:0x007fde81898868 id: 1, ... >, ...]
For consistency, we want each post's author not only to be the same record, but the same
instance in memory. If I modify one author's attributes, I expect that change to be reflected no
matter with inverse I'm working with. Let's confirm by inspecting the :object_id
:
author.object_id
# => 70296816370740
object_ids = [author.object_id] + author.posts.map { |post| post.author.object_id }
# => [70296816370740, 70296816370740, 70296816370740, ... ]
object_ids.uniq.size == 1
# => true
Great!, This means we can say that, for the Author
class, :author
is the "inverse of" the
has_many :posts
association. So we could add the :inverse_of
option to specify the name of the
inverse association to ensure our object instances match up.
# app/models/author.rb
class Author < ActiveRecord::Base
has_many :posts, inverse_of: :author
end
# app/models/post.rb
class Post < ActiveRecord::Base
belongs_to :author, inverse_of: :posts
end
For this example, providing this option will not change the behavior because Rails is already setting the correct inverse instances as we might expect.
It may seem obvious, but Rails has to do some work to set the inverse instance on records in an association and must infer the object based on the class name and association name.
So it should just work™!
It Doesn't Always Work
I noticed something odd the other day.
I was reviewing code for our Rails app which introduced abstraction to render a list of items given by a has_many
association. The code was passing around the inverse instance (the original owner of the association) all over the place.
Wouldn't we expect the inverse to be available on our has_many
items?
Let's look at an oversimplified example of what we were dealing with. Building on our Author
and Post
from earlier, we'll add a Tweet
class. Using ActiveRecord's single-table inheritance mechanism, Tweet
inherits functionality from Post
.
# app/models/author.rb
class Author < ActiveRecord::Base
has_many :posts
has_many :tweets, class_name: 'Tweet'
end
# app/models/post.rb
class Post < ActiveRecord::Base
belongs_to :author
end
# app/models/tweet.rb
class Tweet < Post
validates :text, length: { maximum: 140 }
end
The Author
class has_many :tweets
and each tweet has an author since it inherits its associations from Post
.
tweet = Tweet.last
#=> #<Tweet:0x007fc24c1dadd8 id: 10, ... >
tweet.author
#=> #<Author:0x007fc248e11998# id: 1, ... >
The code was rendering each tweet in a list and each tweet needed to refer back to the author for additional data.
author = Author.find(1)
# Author Load (0.2ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = $1 LIMIT 1 [["id", 1]]
#=> #<Author:0x007fc24dee1ad0 ...>
author.tweets.map { |tw| author.twitter_handle }
# Tweet Load (0.3ms) SELECT "posts".* FROM "posts" WHERE "posts"."type" IN ('Tweet') AND "posts"."author_id" = $1 [["author_id", 1]]
#=> ['vicenta', 'vicenta', ... ]
It seemed odd to pass the author author around.
Each tweet
defines its author
association since it inherits from Post
. I knew my colleague would have had a good reason for passing the author
instance along so I opened up a rails console
to find out what happened if I used the inverse association instead:
author = Author.find(1)
# Author Load (0.2ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = $1 LIMIT 1 [["id", 1]]
#=> #<Author:0x007fc24dee1ad0 ...>
author.tweets.map { |tw| tw.author.twitter_handle }
# Tweet Load (0.3ms) SELECT "posts".* FROM "posts" WHERE "posts"."type" IN ('Tweet') AND "posts"."author_id" = $1 [["author_id", 1]]
# Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = $1 LIMIT 1 [["id", 1]]
# Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = $1 LIMIT 1 [["id", 1]]
# Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = $1 LIMIT 1 [["id", 1]]
# Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = $1 LIMIT 1 [["id", 1]]
# Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = $1 LIMIT 1 [["id", 1]]
# Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = $1 LIMIT 1 [["id", 1]]
# Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = $1 LIMIT 1 [["id", 1]]
# ...
#=> ['vicenta', 'vicenta', ... ]
That's a lot of database queries for one author!
It's the classic problem with has_many
associations: the "N+1" query. After the initial author.tweet
query, "N" additional queries are needed to call each tweet.author
back through the belongs_to
association. We were avoiding the extra lookups by passing around the original author instance.
This is unfortunate because we, as we have seen, it should be possible to avoid these extra queries so that each tweet's author points to the same author object in memory.
Not only do we want to avoid the extra queries, but if modifications are made in one place, we'd like them to be reflected elsewhere. I want to avoid something like this:
tweet_1 = author.tweets.first
tweet_2 = author.tweets.second
tweet_1.author.name # => "Cecily"
tweet_2.author.name # => "Cecily"
tweet_1.author.name = "Martha"
tweet_1.author.name # => "Martha"
tweet_2.author.name # => "Cecily"
So passing the author
instance variable into the block, as an additional argument to method calls, or down to a view template is one workaround. But this can be difficult to maintain, especially if we're dealing with more than one author's posts. Wouldn't it be better not to make those unnecessary queries?
Well, it's possible! :inverse_of
to the rescue.
class Author < ActiveRecord::Base
has_many :tweets, inverse_of: :author
end
class Tweet < ActiveRecord::Base
belongs_to :author, inverse_of: :tweets
end
Now when iterate over the tweets and reference the author, no additional queries are needed because each tweet can now assign its author association from the instance that exists already in memory:
author = Author.find(1)
# Author Load (0.3ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = $1 LIMIT 1 [["id", 1]]
#=> #<Author:0x007fc24c65c028 ... >
author.tweets.map { |tw| tw.author.twitter_handle }
# Tweet Load (0.3ms) SELECT "posts".* FROM "posts" WHERE "posts"."type" IN ('Tweet') AND "posts"."author_id" = $1 [["author_id", 1]]
=> ["vicenta", ... ]
Notice that additional queries for the author (Author Load...
) don't appear in the query log: no more "N+1"!
You might be asking... why doesn't Rails just do this by default all the time? That's a good question. Turns out, it's not so easy. The Rails guides say:
Every association will attempt to automatically find the inverse association and set the
:inverse_of
option heuristically (based on the association name). Most associations with standard names will be supported.
So Rails will "try hard" to make the inverse association work automatically to prevent the extra queries. If no name is found with the :inverse_of
key in the association options, ActiveRecord will try to find the inverse association automatically inferring the class name from the association name, i.e. as Post
is implied by has_many :posts
.
To summarize, when the name of the association and the name of the class Rails expects to find in the association don't match, or other certain other options are uses, automatic inverse lookup won't happen. Then you may see extra queries for objects that already exist in memory.
Avoid Uncertainty, Be Explicit
Here's my recommendation:
Set the :inverse_of
option wherever you can.
Yeah, Rails will try hard to do automatic inverses on your behalf, but leaving it up to Rails adds uncertainty. The uncertainty makes me uncomfortable.
Also know that other ActiveRecord options can interfere with automatic inverses: for example, using :foreign_key
in your association will make it impossible to guess the inverse. In these cases, if you expect to have inverses set properly, using :inverse_of
is necessary.
Here's an opportunity to reduce the chances that a name change or a Rails upgrade will introduce unexpected behavior to your application. I don't really want to write tests to be sure I'm not unintentionally generating a "N+1" queries for my associations. I want to make it easier to introduce other changes into my app later.
Beware of the gotchas: :inverse_of
will only work with has_many
, has_one
, and belong_to
associations and they will not work with the :as
, :polymorphic
, and :through
options. Check out to the Rails docs on bi-directional associations for more info.
Save yourself the trouble and set :inverse_of
for valid belongs_to
, has_many
, and has_one
associations.