Rails framework is famous for developers' happiness and making things simpler due to its magic, provided developers follow proper conventions.
To extend this magic and to make things simple further, Rails 7 has introduced a change with this PR after which, inverse_of
would be inferred automatically for model associations having scopes.
In this article, we'll dive into understanding it with examples.
Let's say we have a Project model with many assigned
tasks.
# app/models/project.rb
class Project < ActiveRecord::Base
has_many :tasks, -> { assigned }
end
# app/models/task.rb
class Task < ActiveRecord::Base
belongs_to :project
scope :assigned, -> { where.not(assigned_user: nil) }
scope :unassigned, -> { where(assigned_user: nil) }
end
Verification using tests
Our expectation here is that even with the presence of a scope, bidirectional association should work such that task.project
returns the project
instance.
# test/models/project_test.rb
class ProjectTest < ActiveSupport::TestCase
def test_project_inverse_of_scoped_task
project = Project.new
task = project.tasks.build
assert_equal task.project, project
end
end
Before Rails 7
Automatic detection of inverse_of
worked only on a simple has_many: belongs_to / has_one: belongs_to
association. If the same association had other options like a custom scope or a custom foreign_key, the automatic detection would not work.
irb(main):001:0> project = Project.first
irb(main):002:0> task = project.tasks.first
irb(main):003:0> project == task.project
Project Load (0.2ms) SELECT "projects".* FROM "projects" WHERE "projects"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
=> true
As we can see, a SQL query is fired when we access the project
using the task
object. If we wanted Rails to not fire the query and instead fetch the original project
object from memory, we'd have to pass inverse_of: :project
option manually. Rails would not detect it because of the assigned
scope.
The test below fails as task.project
returns nil
instead of the project
instance.
$ rails test test/models/project_test.rb --verbose
# Running:
ProjectTest#test_project_inverse_of_scoped_task = 0.24 s = F
Failure:
ProjectTest#test_project_inverse_of_scoped_task [/test/models/project_test.rb:8]:
--- expected
+++ actual
@@ -1 +1 @@
-nil
+#<Project id: nil, name: nil, archived: nil, created_at: nil, updated_at: nil>
rails test test/models/project_test.rb:4
Finished in 1.004950s, 0.9951 runs/s, 0.9951 assertions/s.
1 runs, 1 assertions, 1 failures, 0 errors, 0 skips
With Rails >= 7
Despite the scope applied to the tasks
association, Rails would automatically detect inverse_of
.
irb(main):001:0> project = Project.first
irb(main):002:0> task = project.tasks.first
irb(main):003:0> project == task.project
=> true
Here, as expected Rails fetched the project
object from the cache instead of making any SQL queries. The same is validated by the passing test below.
$ rails test test/models/project_test.rb --verbose
# Running:
ProjectTest#test_project_inverse_of_scoped_task = 0.18 s = .
Finished in 0.629270s, 1.5891 runs/s, 1.5891 assertions/s.
1 runs, 1 assertions, 0 failures, 0 errors, 0 skips
Points to Note
-
The automatic detection of
inverse_of
would not work with an unscoped/scopedbelongs_to
association. -
The automatic detection still won't work for scoped associations that contain the
:through
or:foreign_key
options. -
The default value of
automatic_scope_inversing
forconfig.load_defaults 7.0
is true. Forconfig.load_defaults < 7.0
we need to add the following configuration to opt-in:# config/environments/application.rb config.active_record.automatic_scope_inversing = true
Conclusion
You don't have to mention inverse_of
explicitly for each one of your scoped associations anymore! Even if you never really added inverse_of
to your scoped associations, Rails 7 will identify it for you automatically and save some of your queries.