Rails多个外键关联

94
我希望能够使用一个表格中的两列来定义关系。以任务应用程序为例。 尝试1:
class User < ActiveRecord::Base
  has_many :tasks
end

class Task < ActiveRecord::Base
  belongs_to :owner, class_name: "User", foreign_key: "owner_id"
  belongs_to :assignee, class_name: "User", foreign_key: "assignee_id"
end

那么,Task.create(owner_id:1, assignee_id: 2) 可以让我执行 Task.first.owner,它将返回用户一的信息,而Task.first.assignee则返回用户二的信息。但是,User.first.task 返回为空,这是因为任务不属于用户,它们属于任务的所有者受托人。所以,

尝试2:

class User < ActiveRecord::Base
  has_many :tasks, foreign_key: [:owner_id, :assignee_id]
end

class Task < ActiveRecord::Base
  belongs_to :user
end

由于两个外键似乎不被支持,因此那样做完全失败了。

所以我想要的是能够使用 User.tasks 来获取用户拥有和分配的任务。

基本上,以某种方式建立一个关系,该关系等同于查询 Task.where(owner_id || assignee_id == 1)

这可能吗?

更新

我不想使用 finder_sql,但是这个问题的未被接受的答案看起来接近我想要的: Rails - Multiple Index Key Association

因此,这个方法将如下所示:

尝试3:

class Task < ActiveRecord::Base
  def self.by_person(person)
    where("assignee_id => :person_id OR owner_id => :person_id", :person_id => person.id
  end 
end

class Person < ActiveRecord::Base

  def tasks
    Task.by_person(self)
  end 
end

虽然我可以在Rails 4中让它工作,但我一直收到以下错误:

ActiveRecord::PreparedStatementInvalid: missing value for :owner_id in :donor_id => :person_id OR assignee_id => :person_id

2
这个宝石是否符合您的要求?https://github.com/composite-primary-keys/composite_primary_keys - mus
谢谢你的信息,但这不是我要找的。我想要一个查询,可以根据给定值查询任一列。而不是复合主键。 - JonathanSimmons
是的,更新使其更清晰了。忘记宝石吧。我们都认为你只想使用组合主键。这应该至少可以通过定义自定义作用域和作用域关系来实现。有趣的场景。我稍后会看一下它。 - dre-hh
就我而言,我的目标是获取特定用户的任务并保留 ActiveRecord::Relation 格式,以便我可以继续在搜索/过滤结果上使用任务范围。 - JonathanSimmons
8个回答

90

TL;DR

class User < ActiveRecord::Base
  def tasks
    Task.where("owner_id = ? OR assigneed_id = ?", self.id, self.id)
  end
end

User类中删除has_many :tasks


在我们的tasks表中没有任何名为user_id的列,因此使用has_many :tasks完全没有意义。

以我个人的经验来解决这个问题,我做了以下操作:

class User < ActiveRecord::Base
  has_many :owned_tasks,    class_name: "Task", foreign_key: "owner_id"
  has_many :assigned_tasks, class_name: "Task", foreign_key: "assignee_id"
end

class Task < ActiveRecord::Base
  belongs_to :owner,    class_name: "User"
  belongs_to :assignee, class_name: "User"
  # Mentioning `foreign_keys` is not necessary in this class, since
  # we've already mentioned `belongs_to :owner`, and Rails will anticipate
  # foreign_keys automatically. Thanks to @jeffdill2 for mentioning this thing 
  # in the comment.
end

这样,您就可以调用 User.first.assigned_tasksUser.first.owned_tasks

现在,您可以定义一个名为 tasks 的方法,该方法返回 assigned_tasksowned_tasks 的组合。

从可读性的角度来看,这可能是一个不错的解决方案,但从性能的角度来看,它并不那么好,因为现在需要发出两个查询才能获取tasks,然后还需要将这两个查询的结果合并。

因此,为了获取属于用户的任务,我们将以以下方式在 User 类中定义自定义的 tasks 方法:

def tasks
  Task.where("owner_id = ? OR assigneed_id = ?", self.id, self.id)
end

这种方式可以在一次查询中获取所有结果,我们不需要合并或组合任何结果。


这个解决方案很棒!它确实需要分别访问分配、拥有和所有任务。在大型项目中,这将是一个很好的预见。然而,在我的情况下,这不是必需的。至于“has_many”“没有意义”,如果您阅读接受的答案,您会发现我们根本没有使用“has_many”声明。假设您的要求符合其他访问者的需求是无效的,这个答案可以更少地带有评判性。 - JonathanSimmons
话虽如此,我确信这是我们应该向遇到这个问题的人们推荐的更好的长期设置。如果您能修改您的答案,包括其他模型的基本示例和用户方法来检索组合任务集,我将把它作为所选答案进行修订。谢谢! - JonathanSimmons
是的,我同意你的观点。使用owned_tasksassigned_tasks来获取所有任务将会有性能提示。无论如何,我已经更新了我的答案,并在User类中包括了一个方法来获取所有关联的tasks,它将在一次查询中提取结果,不需要合并/组合操作。 - Arslan Ali
这不是一个理想的解决方案,因为您正在失去用户范围,并且不能再像这样组合范围了。 User.join(:tasks).order_by('tasks.name') - dre-hh
此解决方案不保留真正的“关联”。 - s2t2
显示剩余5条评论

60

在 @dre-hh 上面的答案基础上扩展,但我发现在 Rails 5 中不再按预期工作。看起来 Rails 5 现在包括一个默认的 where 子句,类似于 WHERE tasks.user_id = ?,但是在这种情况下没有 user_id 列,导致失败。

我发现可以通过 has_many 关联仍然使其工作,你只需要取消 Rails 添加的这个额外的 where 子句。

class User < ApplicationRecord
  has_many :tasks, ->(user) {
    unscope(:where).where(owner: user).or(where(assignee: user)
  }
end

谢谢@Dwight,我从来没有想过这个! - Gabe Kopley
@fgblomqvist belongs_to 有一个选项叫做 :primary_key,它指定了用于关联的关联对象的主键返回方法。默认情况下,这是 id。我认为这可能会对你有所帮助。 - Ahmed Kamal
@AhmedKamal 我不明白那有什么用处? - fgblomqvist
@dwight 你有测试过这个解决方案吗?我的意思是,一切都运行得很好,但是在运行 expect(described_class.new).to(have_many(:tasks)) 后,我收到了 Task does not have a user_id foreign key 错误。 - weezing
@weezing 我还没有使用 shoulda 进行测试,而是使用实际的数据库测试来确保我获得了预期的结果。 - Dwight
显示剩余2条评论

25

Rails 5:

如果你想要一个 has_many 关联,请参考 @Dwight 的回答,需要取消默认的 where 子句,可以使用 unscope 方法。

尽管 User.joins(:tasks) 可以给我

ArgumentError: The association scope 'tasks' is instance dependent (the scope block takes an argument). Preloading instance dependent scopes is not supported.

由于现在已经不可能了,您也可以使用 @Arslan Ali 的解决方案。

Rails 4:

class User < ActiveRecord::Base
  has_many :tasks, ->(user){ where("tasks.owner_id = :user_id OR tasks.assignee_id = :user_id", user_id: user.id) }
end

更新1: 关于@JonathanSimmons的评论:

需要将用户对象传递到用户模型作用域似乎是一种倒退的方法。

您不必将用户模型传递给此作用域。当前用户实例会自动传递到此lambda表达式中。像这样调用它:

user = User.find(9001)
user.tasks

更新2:

如果可能的话,您能否扩展一下这个答案以解释正在发生的事情?我想更好地理解它,以便我可以实现类似的东西。谢谢

在ActiveRecord类上调用has_many:tasks将在某些类变量中存储一个lambda函数,这只是一种生成其对象上的tasks方法的花式方式,该方法将调用此lambda。 生成的方法看起来类似于以下伪代码:

class User

  def tasks
   #define join query
   query = self.class.joins('tasks ON ...')
   #execute tasks_lambda on the query instance and pass self to the lambda
   query.instance_exec(self, self.class.tasks_lambda)
  end

end

1
太棒了,在其他地方我看到的人都试图建议使用过时的 gem 来处理复合键。 - FireDragon
2
如果可能的话,您能否扩展一下这个答案并解释一下正在发生什么?我想更好地理解它,以便我可以实现类似的东西。谢谢。 - Stephen Lead
2
尽管这样保持了关联,但会引起其他问题。从文档中可以看到:注意:无法完全加入、急切加载和预加载这些关联。这些操作在实例创建之前发生,并且作用域将被调用为nil参数。这可能导致意外行为,并已被弃用。http://api.rubyonrails.org/classes/ActiveRecord/Associations/ClassMethods.html#module-ActiveRecord::Associations::ClassMethods-label-Accessing+the+owner+object - s2t2
1
这看起来很有前途,但在Rails 5中仍然使用where查询与外键一起使用,而不是仅使用上面的lambda。 - Mohamed El Mahallawy
1
是的,在Rails 5中似乎这个不再起作用了。 - Dwight
显示剩余2条评论

13

我已经找到了解决方案。欢迎提出任何建议以改进它。

class User < ActiveRecord::Base

  def tasks
    Task.by_person(self.id)
  end 
end

class Task < ActiveRecord::Base

  scope :completed, -> { where(completed: true) }   

  belongs_to :owner, class_name: "User", foreign_key: "owner_id"
  belongs_to :assignee, class_name: "User", foreign_key: "assignee_id"

  def self.by_person(user_id)
    where("owner_id = :person_id OR assignee_id = :person_id", person_id: user_id)
  end 
end

这基本上覆盖了has_many关联,但仍然返回我需要的ActiveRecord :: Relation对象。

所以现在我可以这样做:

User.first.tasks.completed,结果是所有由第一个用户拥有或分配的已完成任务。


1
这个和下面dre-hh的回答会实现相同的功能吗? - dkniffin
OddityOverseer的回答基本上是肯定的。尽管我觉得他的回答在语义上有些问题。在User模型的作用域内传递用户对象似乎是一种倒退的方法。因此,我的版本允许使用User.find(1).tasks,而dre-hh的回答则需要类似于User.find(1).tasks(User.find(1))这样的东西。 - JonathanSimmons
同意。我找不到直接通过关联来完成这个任务的好方法,所以我认为你的方法对于这个问题是最好的。 - dkniffin
1
不需要将用户对象传递到用户模型的作用域中,这似乎是一种倒退的方法。您无需传递任何内容,这是一个lambda函数,当前用户实例会自动传递给它。 - dre-hh
@dre-hh,我不知道它只是动态设置 lambda。很酷!此时,这三个答案都提供了不同的方法来处理相同的事情。个人而言,当需要任何类型的变量时,我倾向于使用方法定义的范围,而不是使用 Rails 范围简写形式。但这只是一种偏好。 - JonathanSimmons
显示剩余3条评论

3

从Rails 5开始,您还可以使用更安全的ActiveRecord方式来执行此操作:

def tasks
  Task.where(owner: self).or(Task.where(assignee: self))
end

2

我对Rails(3.2)中的关联和(多个)外键:如何在模型中描述它们并编写迁移的回答只为你而来!

至于你的代码,这是我的修改意见。

class User < ActiveRecord::Base
  has_many :tasks, ->(user) { unscope(where: :user_id).where("owner_id = ? OR assignee_id = ?", user.id, user.id) }, class_name: 'Task'
end

class Task < ActiveRecord::Base
  belongs_to :owner, class_name: "User", foreign_key: "owner_id"
  belongs_to :assignee, class_name: "User", foreign_key: "assignee_id"
end

警告: 如果您正在使用RailsAdmin并需要创建新记录或编辑现有记录,请不要按照我建议的做法。因为这种黑客行为会在您执行类似以下操作时引起问题:

current_user.tasks.build(params)

原因在于Rails将尝试使用current_user.id填充task.user_id,但却发现根本不存在user_id这样的属性。

所以,虽然我的hack方法可以说是一种超越传统思维的创造性方式,但请不要这样做。


不确定这个答案是否提供了之前审批的答案所没有提供的信息。此外,它感觉就像是另一个问题的广告。 - JonathanSimmons
@JonathanSimmons 谢谢。如果像广告一样行事不好,我会删除这个回答。 - sunsoft

0
更好的方法是使用多态关联:

task.rb

class Task < ActiveRecord::Base
  belongs_to :taskable, polymorphic: true
end

assigned_task.rb

class AssignedTask < Task
end

owned_task.rb

class OwnedTask < Task
end

user.rb

class User < ActiveRecord::Base
  has_many :assigned_tasks, as: :taskable, dependent: :destroy
  has_many :owned_tasks,    as: :taskable, dependent: :destroy
end

最终,我们可以这样使用它:

new_user = User.create(...)
AssignedTask.create(taskable: new_user, ...)
OwnedTask.create(taskable: new_user, ...)

pp user.assigned_tasks
pp user.owned_tasks

0
我们遇到了一个类似的情况,需要为belongs_to设置2个外键,我们按照以下方式进行了修复:
class User < ActiveRecord::Base
  has_many :tasks
end

class Task < ActiveRecord::Base
  belongs_to :user, ->(task) { where(assignee_id: task.assignee_id) }, foreign_key: :owner_id
end

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接