Exploring the `:inverse_of` Option on Rails Model Associations
Ryan Stenberg, Former Developer
Article Category:
Posted on
Ever seen the :inverse_of
option on an association and wondered what it did and why it was there?
class Criminal < ActiveRecord::Base
belongs_to :prison, inverse_of: :criminals
end
class Prison < ActiveRecord::Base
has_many :criminals, inverse_of: :prison
end
The first time I saw something like that, it seemed like an unnecessary distinction to make. Why is :inverse_of
a thing?
Memory Optimization When Fetching Associated Records
It turns out that associated objects do not point to the same in-memory objects by default. To illustrate:
prison = Prison.create(name: 'Bad House')
criminal = prison.criminals.create(name: 'Krazy 8')
# Without :inverse_of
criminal.prison == prison
# Prison Load (0.1ms) SELECT "prisons".* FROM "prisons" WHERE "prisons"."id" = 2 LIMIT 1
# => true
# With :inverse_of
criminal.prison == prison
# => true
When we call criminal.prison
without :inverse_of
on both the :belongs_to
and :has_many
associations, it will hit the database. With :inverse_of
, if we already have that prison record in memory then criminal.prison
will point to the same prison.
It's worth clarifying that this only saves you a database look-up when going from Criminal
to Prison
(:belongs_to
direction). Here's some console output going from the other direction:
prison = Prison.last
# Prison Load (0.1ms) SELECT "prisons".* FROM "prisons" ORDER BY "prisons"."id" DESC LIMIT 1
# => #<Prison id: 3, name: "Broadmoor", created_at: "2014-10-10 20:26:38", updated_at: "2014-10-10 20:26:38">
criminal = prison.criminals.first
# Criminal Load (0.3ms) SELECT "criminals".* FROM "criminals" WHERE "criminals"."prison_id" = 3 LIMIT 1
# => #<Criminal id: 3, name: "Charles Bronson", prison_id: 3, created_at: "2014-10-10 20:26:47", updated_at: "2014-10-10 20:26:47">
prison.criminals.first == criminal
# Criminal Load (0.2ms) SELECT "criminals".* FROM "criminals" WHERE "criminals"."prison_id" = 3 LIMIT 1
# => true
A model's associations, as far as memory is concerned, are one-way bindings. The :inverse_of
option basically gives us two-way memory bindings when one of the associations is a :belongs_to
. A memory optimization isn't the only thing that :inverse_of
gets you. Next, we'll take a look at two use cases involving the creation of associated records.
Creating an object and its children via accepts_nested_attributes_for
in a :has_many
association
Say we have a form in our app where we want the user to be able to create a prison and add one or more prisoners. Here's what our model landscape might look like:
class Prison < ActiveRecord::Base
has_many :criminals, inverse_of: :prison
accepts_nested_attributes_for :criminals
end
class Criminal < ActiveRecord::Base
belongs_to :prison, inverse_of: :criminals
validates :prison, presence: true
end
Without :inverse_of
on the prison's has_many :criminals
, here's what would happen upon form submission:
params = { name: 'Alcatraz', criminals_attributes: [{ name: 'Al Capone' }] }
# => {:name=>gt;"Alcatraz", :criminals_attributes=>[{:name=>"Al Capone"}]}
Prison.create(params)
(0.1ms) begin transaction
(0.1ms) rollback transaction
# => #<Prison id: nil, name: "Alcatraz", created_at: nil, updated_at: nil>
Prison.create!(params)
(0.1ms) begin transaction
(0.1ms) rollback transaction
ActiveRecord::RecordInvalid: Validation failed: Criminals prison can't be blank
When Rails attempts to save the criminal, the prison has not yet been committed into the database and is therefore missing an id
. This causes the criminal's validate :prison, presence: true
validation to fail.
In order for this to work, we need to have that :inverse_of
defined as illustrated in the example, then we can create our associated records as desired:
params = { name: 'Alcatraz', criminals_attributes: [{ name: 'Al Capone' }] }
# => {:name=>"Alcatraz", :criminals_attributes=>[{:name=>"Al Capone"}]}
Prison.create(params)
(0.1ms) begin transaction
SQL (3.0ms) INSERT INTO "prisons" ("created_at", "name", "updated_at") VALUES (?, ?, ?) [["created_at", Mon, 13 Oct 2014 16:34:11 UTC +00:00], ["name", "Alcatraz"], ["updated_at", Mon, 13 Oct 2014 16:34:11 UTC +00:00]]
SQL (0.2ms) INSERT INTO "criminals" ("created_at", "name", "prison_id", "updated_at") VALUES (?, ?, ?, ?) [["created_at", Mon, 13 Oct 2014 16:34:11 UTC +00:00], ["name", "Al Capone"], ["prison_id", 4], ["updated_at", Mon, 13 Oct 2014 16:34:11 UTC +00:00]]
(2.3ms) commit transaction
# => #<Prison id: 4, name: "Alcatraz", created_at: "2014-10-13 16:34:11", updated_at: "2014-10-13 16:34:11">
Creating associated objects across a has_many :through
Another place where you might run into issues is when you're creating an associated object on the other side of a has_many :through
. Let's make another change to our example to illustrate this use case:
class Criminal < ActiveRecord::Base
has_many :sentences, inverse_of: :criminal
has_many :prisons, through: :sentences
end
class Sentence < ActiveRecord::Base
belongs_to :criminal, inverse_of: :sentences
belongs_to :prison, inverse_of: :sentences
end
class Prison < ActiveRecord::Base
has_many :sentences, inverse_of: :prison
has_many :criminals, through: :sentences
end
Now, our criminals can serve multiple sentences and be associated to multiple prisons. If we wanted to do something like..
prison = Prison.create(name: 'Alcatraz')
criminal = prison.criminals.build(name: 'Al Capone')
# then save our criminal record..
Without the inverses, here's what we'd get:
criminal.save
(0.1ms) begin transaction
SQL (0.4ms) INSERT INTO "criminals" ("created_at", "name", "prison_id", "updated_at") VALUES (?, ?, ?, ?) [["created_at", Mon, 13 Oct 2014 12:37:51 UTC +00:00], ["name", "Al Capone"], ["prison_id", nil], ["updated_at", Mon, 13 Oct 2014 12:37:51 UTC +00:00]]
(8.5ms) commit transaction
# => true
criminal.prisons
Prison Load (0.2ms) SELECT "prisons".* FROM "prisons" INNER JOIN "sentences" ON "prisons"."id" = "sentences"."prison_id" WHERE "sentences"."criminal_id" = 8
# => []
criminal.sentences
Sentence Load (0.1ms) SELECT "sentences".* FROM "sentences" WHERE "sentences"."criminal_id" = 8
# => []
Prisoner on the loose! Our prisoner should have had a sentence for our Alcatraz prison.
Without :inverse_of
on the :belongs_to
associations in Sentence
, our criminal doesn't have any prisons because the necessary sentence wouldn't be automatically created. Rails needs to know the inverses on the Sentence
model in order to automatically create it.
With inverses:
criminal.save
(0.1ms) begin transaction
SQL (5.9ms) INSERT INTO "criminals" ("created_at", "name", "updated_at") VALUES (?, ?, ?) [["created_at", Mon, 13 Oct 2014 12:50:17 UTC +00:00], ["name", "Al Capone"], ["updated_at", Mon, 13 Oct 2014 12:50:17 UTC +00:00]]
SQL (0.4ms) INSERT INTO "sentences" ("created_at", "criminal_id", "duration", "prison_id", "updated_at") VALUES (?, ?, ?, ?, ?) [["created_at", Mon, 13 Oct 2014 12:50:17 UTC +00:00], ["criminal_id", 5], ["duration", nil], ["prison_id", 9], ["updated_at", Mon, 13 Oct 2014 12:50:17 UTC +00:00]]
(8.3ms) commit transaction
# => true
criminal.prisons
Prison Load (0.2ms) SELECT "prisons".* FROM "prisons" INNER JOIN "sentences" ON "prisons"."id" = "sentences"."prison_id" WHERE "sentences"."criminal_id" = 5
# => [#<Prison id: 9, name: "Alcatraz", created_at: "2014-10-13 12:40:55", updated_at: "2014-10-13 12:40:55">]
criminal.sentences
# => [#<Sentence id: 3, prison_id: 9, criminal_id: 5, duration: nil, created_at: "2014-10-13 12:50:17", updated_at: "2014-10-13 12:50:17">]
Great justice! Our prisoner is correctly sentenced with Alcatraz.
Good News
As of version 4.1, Rails will try to automatically set the :inverse_of
option for you (pull request). Given our example with Criminal
having a :belongs_to
association with Prison
, it will attempt to derive the inverse from the class name -- in this case :criminals
based on the class Criminal
. Obviously, this falls apart when the names do not match up. For example, when using :class_name
or :foreign_key
options on your associations. In that case, :inverse_of
has to be explicitly set to the correct names, so it's still worth knowing how :inverse_of
works.