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

  1. The automatic detection of inverse_of would not work with an unscoped/scoped belongs_to association.

  2. The automatic detection still won't work for scoped associations that contain the :through or :foreign_key options.

  3. The default value of automatic_scope_inversing for config.load_defaults 7.0 is true. For config.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.

References