参数化的Rails关联

78 阅读3分钟

这篇文章展示了如何定义依赖于一些全局对象的关联,如当前用户(我们也可以把这些东西称为多租户关联)。

问题

想象一个有三个模型的Rails项目。用户、艺术家、歌曲。匿名用户只能看到已发布的歌曲,认证用户可以看到所有歌曲:

class User < ApplicationRecord
end

class Song < ApplicationRecord
  enum :status, {draft: 'draft', published: 'published'}
  belongs_to :artist
end

class Artist < ApplicationRecord
  has_many :songs
end

在控制器的某个地方,有一段代码可以优化N+1问题:

@artists = Artist.includes(:song)

songs 但是在Artist 上定义的关联没有意识到我们的要求,即它需要只为未登录的用户列出已发表的歌曲。所以它总是会返回所有的歌曲。

请看下面如何在不改变关联名称的情况下解决这个问题。

保持关联名称是一个重要的要求。它不需要在现有应用程序有.includes(:song) 的地方改变代码。这样,它就消除了回归和被遗忘的地方可能出现的错误。

解决方案

ActiveRecord没有任何允许参数化关联的功能。幸运的是,它允许指定一个可选的lambda,可以减少返回对象的范围。听起来正是我们所需要的。 不幸的是,它不知道当前用户是否存在(已认证)。 但我们可以定义一个全局线程安全变量并在那里使用它。

这个全局变量可以通过ApplicationRecord ,在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

然后在关联中使用它就变得很容易了:

class Artist < ApplicationRecord
  has_many :songs, -> { Thread.current[:current_user] ? all : where(status: :published) }
end

应用程序开始按照新的要求工作,即匿名者只能看到已发布的歌曲。 注意,应用程序的其他地方保持不变,不需要任何改变。

这就是整个解决方案。就这么简单。

让我们检查一下它的操作:

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, ...]]

它没有设置Thread.current[:current_user] - 假设它是一个匿名用户的请求。这就是为什么结果中只有已发表的歌曲。

在下一个测试中,我们指定Thread.current[:current_user] - 在控制台中这样做,我们有点模拟在控制器中运行上面的周围过滤器:

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, ...>]]

现在它返回所有歌曲的状态。你也可以在生成的SQL中看到这一点。

关联变得非常聪明,非常灵活。

缺点

  1. 这个解决方案在你面临的情况下是有效的,因为当一个地方需要减少结果,同时另一个地方需要结果中的所有项目。 在这种情况下,这个解决方案应该是先进的(是的,有可能使它更聪明)或被其他东西取代。

  2. 我应用这个解决方案的项目在测试中使用了数据库清洁器gem。 尽管,下面的 RSpec 示例看起来不错,但它会破坏这个 gem 的工作。

around do |example|
  Thread.current[:current_user] = user
  example.run
ensure
  Thread.current[:current_user] = nil
end

在上面的ensure 块中,代码清除了全局var,并在数据库清洁器钩子后运行。但所有保存在Thread.current 中的对象并没有被数据库清洁器清理。因此,如果项目中有一些规格使用上述的周围块,测试可能开始随机失败,因为DB中保存的用户跨越了测试(测试实例通常依赖于一个空的DB)。

我想出了以下的解决方法:

  • 使用before 块而不是around:
before { Thread.current[:current_user] = user }
  • 改变数据库清洁器的设置:
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

这不是一个非常优雅的解决方案,但它很有效。

总结

在这里可以看到一个工作中的Rails应用程序作为一个例子。

在处理一个问题时,尽量在 "本地 "解决它。这可以让我们找到一个解决方案,避免不必要的全局变化、回归和bug。编码愉快!