This post shows how to define associations dependent on some global object such as the 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 code that addresses the 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 users who aren’t logged in. 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 avoids the 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 us to parameterize associations. Fortunately, it allows specifying an optional lambda that can reduce the scope of returned objects. Sounds like exactly what we need. Unfortunately, it doesn’t know whether 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 users can see only published songs. Note that the other places in the app remain the same and don’t need any changes.
That’s the 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 Thread.current[:current_user] set - assuming it’s an anonymous user request. That’s why the result has only published songs.
In 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 regardless of their status. You also can see that in 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 where I applied this solution uses database cleaner gem in tests. Even though the following RSpec example looks OK, it would break this gem’s behavior:
around do |example|
Thread.current[:current_user] = user
example.run
ensure
Thread.current[:current_user] = nil
end
The code that clears the global variable 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 failing randomly because of the kept users in DB across the tests (test examples usually rely on an empty DB).
I came up with the following workaround:
- Use a
beforeblock 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 to find a solution that avoids unnecessary global changes, regression, and bugs. Happy coding!
Update 28 Apr 2022
I’ve got several pieces of feedback suggesting using 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 the 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) })