Parameterized Rails associations
This post shows how to define associations dependent on some global object such as current user (also we can call these things multi-tenant associations).
Problem
Picture a Rails project with three models: User, Artist, Song. Artist has many Songs/Song belongs to Artist. Anonymous users can see only published songs, authenticated users can see all songs.
class User < ApplicationRecord
end
class Song < ApplicationRecord
enum :status, {draft: 'draft', published: 'published'}
belongs_to :artist
end
class Artist < ApplicationRecord
has_many :songs
end
Somewhere in controllers there is a code that optimizes N+1 problem:
@artists = Artist.includes(:song)
But the songs
association defined on the Artist
is not aware of our requirement that
it needs to list only published songs for not logged-in users. So it would always return all songs.
See how to fix that without changing the association name below.
Keeping the association name is an important requirement. It doesn’t need to change the code everywhere where the existing application has
.includes(:song)
. That way, it eliminates regression and possible bugs from forgotten places.
Solution
ActiveRecord doesn’t have any functionality that would allow to parameterize associations. Fortunately, it allows specifying an optional lambda that can reduce the scope of returning objects. Sounds like exactly what we need. Unfortunately, it’s not aware of the fact if the current user is present (authenticated). But we can define a global thread-safe variable and use it there.
The global variable can be set by ApplicationRecord
within around_filter
:
class ApplicationController < ActionController::Base
around_action :set_current_user_globally
private
def set_current_user_globally
Thread.current[:current_user] = current_user
yield
ensure
Thread.current[:current_user] = nil
end
end
Then it becomes easy to use it within associations:
class Artist < ApplicationRecord
has_many :songs, -> { Thread.current[:current_user] ? all : where(status: :published) }
end
The application starts working according to the new requirement that anonymous can see only published songs. Note, that the other places over the app remain the same and don’t need any changes.
That’s whole solution. It’s as simple as that.
Let’s check it in action:
irb(main):001:0> Artist.includes(:songs).map(&:songs)
(0.9ms) SELECT sqlite_version(*)
Artist Load (0.5ms) SELECT "artists".* FROM "artists"
Song Load (0.7ms) SELECT "songs".* FROM "songs" WHERE "songs"."status" = ? AND "songs"."artist_id" IN (?, ?) [["status", "published"], ["artist_id", 1], ["artist_id", 2]]
=>
[[#<Song:0x00000001100331d8 id: 2, status: "published", artist_id: 1, ...],
[#<Song:0x000000011008be28 id: 3, status: "published", artist_id: 2, ...]]
It has no set Thread.current[:current_user]
- assuming it’s an anonymous user request. That’s why the result has only published songs.
On the next test we specify Thread.current[:current_user]
- doing that in the console we kinda simulate running the around filter above in the controller.
irb(main):002:0> Thread.current[:current_user] = User.first
irb(main):003:0> Artist.includes(:songs).map(&:songs)
Artist Load (0.2ms) SELECT "artists".* FROM "artists"
Song Load (0.4ms) SELECT "songs".* FROM "songs" WHERE "songs"."artist_id" IN (?, ?) [["artist_id", 1], ["artist_id", 2]]
=>
[[#<Song:0x00000001108c82d0 id: 1, status: "draft", artist_id: 1, ...>,
#<Song:0x00000001108c8140 id: 2, status: "published", artist_id: 1, ...>],
[#<Song:0x0000000110923f90 id: 3, status: "published", artist_id: 2, ...>,
#<Song:0x0000000110923e28 id: 4, status: "draft", artist_id: 2, ...>]]
Now it returns all songs regarding their status. You also can see that on the generated SQL.
The association became really smart and very flexible.
Drawbacks
-
This solution works until you face the case when the association should behave differently all over the app. It’s when one place needs the result reduced and at the same time, another place needs all items within the result. In this case, the solution should be advanced (yes, it’s possible to make it even smarter) or replaced by something else.
-
The project I applied this solution uses database cleaner gem in tests. Even though, the following RSpec example looks ok it would break this gem work:
around do |example|
Thread.current[:current_user] = user
example.run
ensure
Thread.current[:current_user] = nil
end
The code clears the global var in the ensure
block above is run after the database cleaner hooks.
But all objects kept within Thread.current
are not being cleaned by database cleaner.
So, if there are some specs in the projects that use an around block like above the tests can start fail randomly because of the kept users in DB across the tests (test examples usually rely on a empty DB).
I come up with the following workaround:
- Use
before
block instead ofaround
.
before { Thread.current[:current_user] = user }
- Change database cleaner setup.
config.after(:each) do
# Thread.current[:current_user] is set in some tests
# it should be cleaned before database cleaner runs, otherwise it won't be dropped from DB.
Thread.current[:current_user] = nil
DatabaseCleaner.clean
end
Not a very elegant solution, but it works well.
Conclusion
See a working Rails app as an example here.
While working on an issue try to fix it “locally”. That allows us find a solution that avoids unnecessary global changes, regression, and bugs. Happy coding!
Update 28 Apr 2022
I’ve got several feedbacks suggesting to use ActiveSupport::CurrentAttributes
instead of Thread.current
. Can’t disagree with that, it looks much safer and cleaner. Thanks to everyone who suggested that.
Update 1 May 2022
Got an interesting and very useful response on reddit. Reposting it here.
Sequel has this functionality out of box:
class Artist < Sequel::Model
one_to_many :songs
end
# somewhere in the controller
Artist.eager(songs: -> (ds) { current_user ? ds : ds.where(status: :published) })
I’ve submitted a feature request to Rails.