Rails中避免has_many :through重复的惯用语法

42

我在Rails应用程序中有一个标准的用户和角色之间的多对多关系:

class User < ActiveRecord::Base
  has_many :user_roles
  has_many :roles, :through => :user_roles
end

我希望确保一个用户只能被分配一次任何角色。任何尝试插入重复的操作应该忽略请求,而不是抛出错误或导致验证失败。我真正想表示的是一个“集合”,其中插入已经存在于集合中的元素没有影响。{1,2,3} U {1} = {1,2,3},而不是{1,1,2,3}。

我意识到可以这样做:

user.roles << role unless user.roles.include?(role)

或者通过创建一个包装方法(例如add_to_roles(role)),但我希望有一种惯用的方式可以通过关联自动完成,这样我就可以写:

user.roles << role  # automatically checks roles.include?

这很方便,对于我来说它做了所有的工作。这样,我就不必记得检查重复项或使用自定义方法。框架里有我错过的东西吗?一开始我想到的是 has_many 的 :uniq 选项可以实现,但它基本上只是“select distinct”。

有没有一种声明性的方法可以做到这一点?如果没有,也许可以使用关联扩展?

这里是默认行为失败的示例:

>> u = User.create
      User Create (0.6ms)   INSERT INTO "users" ("name") VALUES(NULL)
    => #<User id: 3, name: nil>
    >> u.roles << Role.first
      Role Load (0.5ms)   SELECT * FROM "roles" LIMIT 1
      UserRole Create (0.5ms)   INSERT INTO "user_roles" ("role_id", "user_id") VALUES(1, 3)
      Role Load (0.4ms)   SELECT "roles".* FROM "roles" INNER JOIN "user_roles" ON "roles".id = "user_roles".role_id WHERE (("user_roles".user_id = 3)) 
    => [#<Role id: 1, name: "1">]
    >> u.roles << Role.first
      Role Load (0.4ms)   SELECT * FROM "roles" LIMIT 1
      UserRole Create (0.5ms)   INSERT INTO "user_roles" ("role_id", "user_id") VALUES(1, 3)
    => [#<Role id: 1, name: "1">, #<Role id: 1, name: "1">]
8个回答

29
只要追加的角色是一个ActiveRecord对象,你所做的就是:
user.roles << role

应该对:has_many关联自动去重。

对于has_many :through,请尝试:

class User
  has_many :roles, :through => :user_roles do
    def <<(new_item)
      super( Array(new_item) - proxy_association.owner.roles )
    end
  end
end

如果super无法正常工作,您可能需要设置alias_method_chain。


3
为了后人,以上方法可以缩短并泛化为:def <<(*items) super(items - proxy_target) end - KingPong
1
对于Rails 3.1,s/proxy_owner/proxy_association.owner/相关问题 - Turadg
1
为什么在<<只接受单个对象的情况下,要使用参数*items?http://www.ruby-doc.org/core-1.9.3/Array.html#method-i-3C-3C - Turadg
2
对我来说,<< dedupes for has_many 而不是 has_many :through 看起来很奇怪。然而,修复这个问题的 Rails 问题(https://github.com/rails/rails/issues/8573)被拒绝了,理由是“这是你的领域逻辑,所以检查它是你的责任。” - Turadg
@Turadg 现在 << 可以接受多个参数 http://guides.rubyonrails.org/association_basics.html#methods-added-by-has-many-collection-object - lulalala
显示剩余5条评论

29

使用数组的|= join方法。

你可以使用数组的|= join方法,将一个元素添加到数组中,除非该元素已经存在。只需确保将元素放在数组中即可。

role                  #=> #<Role id: 1, name: "1">

user.roles            #=> []

user.roles |= [role]  #=> [#<Role id: 1, name: "1">]

user.roles |= [role]  #=> [#<Role id: 1, name: "1">]

还可以用于添加多个可能存在或不存在的元素:

role1                         #=> #<Role id: 1, name: "1">
role2                         #=> #<Role id: 2, name: "2">

user.roles                    #=> [#<Role id: 1, name: "1">]

user.roles |= [role1, role2]  #=> [#<Role id: 1, name: "1">, #<Role id: 2, name: "2">]

user.roles |= [role1, role2]  #=> [#<Role id: 1, name: "1">, #<Role id: 2, name: "2">]

这个StackOverflow回答中发现了这种技巧。


2
在我看来,正确且最干净的答案。 - DonMB

4

您可以在主模型中使用 validates_uniqueness_of 和重写 << 的组合,但这也会捕获联接模型中的任何其他验证错误。

validates_uniqueness_of :user_id, :scope => [:role_id]

class User
  has_many :roles, :through => :user_roles do
    def <<(*items)
      super(items) rescue ActiveRecord::RecordInvalid
    end
  end
end

2
你能否将那个异常改为 ActiveRecord::RecordNotUnique?我喜欢这个答案。但要注意竞态条件 - Ashitaka
很好的答案。我在没有使用validates_uniqueness_of的情况下使用了它,在数据库中声明了唯一索引,效果非常好。 - Ruby Racer

2

我认为在您的用户角色联接模型中,适当的验证规则应该是:

validates_uniqueness_of :user_id, :scope => [:role_id]

谢谢。但实际上它并不能做我想要的(即类似于集合的行为),我已经在原帖中澄清了这一点。抱歉关于此事。 - KingPong
我认为这是您问题的最佳答案。如果您在创建界面时小心谨慎,用户必须进行黑客攻击才能添加错误的角色,此时验证异常是完全合适的响应。 - austinfromboston
1
嘿,你疯了吗?用户不会添加自己的角色 :-)典型的用例是,用户成为角色的成员,作为其他事情的副作用。例如,购买特定产品。其他产品也可能提供相同的角色,因此存在重复的可能性。我宁愿在一个地方进行重复检查,而不是在需要确保用户具有角色的任意随机位置进行检查。从这个意义上说,给用户一个他已经拥有的角色不是错误条件。 - KingPong

1
今天我遇到了这个问题,最终使用了#replace,它“将执行差异并仅删除/添加已更改的记录”。
因此,您需要传递现有角色的联合(以便它们不会被删除)和您的新角色:
new_roles = [role]
user.roles.replace(user.roles | new_roles)

需要注意的是,无论是这个答案还是被接受的答案都会将相关的roles对象加载到内存中,以执行数组差异(-)和并集(|)。如果你处理大量关联记录,这可能会导致性能问题。

如果这是一个问题,您可以考虑通过查询首先检查存在性的选项,或使用INSERT ON DUPLICATE KEY UPDATE(mysql)类型的查询进行插入。


0
也许可以创建验证规则。
validates_uniqueness_of :user_roles

然后捕获验证异常并优雅地继续执行。然而,这种方法感觉非常粗糙,而且即使可能也很不优雅。


0

即使被多次调用,这将在数据库中仅创建一个关联 参考 Rails 指南

user.roles=[Role.first] 

0

我认为你想要做类似这样的事情:

user.roles.find_or_create_by(role_id: role.id) # saves association to database
user.roles.find_or_initialize_by(role_id: role.id) # builds association to be saved later

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