关于rails上的ruby:has_and_belongs_to_many,避免在连接表中使用dupes

has_and_belongs_to_many, avoiding dupes in the join table

我有一套非常简单的HABTM模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Tag < ActiveRecord::Base
   has_and_belongs_to_many :posts
end

class Post < ActiveRecord::Base
   has_and_belongs_to_many :tags

   def tags= (tag_list)
      self.tags.clear
      tag_list.strip.split(' ').each do
        self.tags.build(:name => tag)
      end
   end
end

现在一切正常,除了我在Tags表中得到了大量的重复项。

我需要做些什么来避免标签表中的重复(基于名称)?


仅在视图中防止重复(Lazy解决方案)

以下内容不会阻止将重复关系写入数据库,它只能确保find方法忽略重复项。

在Rails 5中:

1
has_and_belongs_to_many :tags, -> { distinct }

注意:Relation#uniq在Rails 5(提交)中被折旧

在Rails 4中

1
has_and_belongs_to_many :tags, -> { uniq }

防止保存重复数据(最佳解决方案)

选项1:防止来自控制器的重复:

1
post.tags << tag unless post.tags.include?(tag)

但是,多个用户可能同时尝试post.tags.include?(tag),因此这受到竞争条件的影响。这在这里讨论。

为了增强稳定性,您还可以将其添加到Post模型(post.rb)

1
2
3
def tag=(tag)
  tags << tag unless tags.include?(tag)
end

选项2:创建唯一索引

防止重复的最简单方法是在数据库层具有重复的约束。这可以通过在表本身上添加unique index来实现。

1
2
3
4
rails g migration add_index_to_posts
# migration file
add_index :posts_tags, [:post_id, :tag_id], :unique => true
add_index :posts_tags, :tag_id

获得唯一索引后,尝试添加重复记录将引发ActiveRecord::RecordNotUnique错误。处理此问题超出了本问题的范围。查看这个问题。

1
rescue_from ActiveRecord::RecordNotUnique, :with => :some_method


另外上面的建议:

  • :uniq添加到has_and_belongs_to_many关联
  • 在连接表上添加唯一索引
  • 我会做一个明确的检查,以确定该关系是否已经存在。例如:

    1
    2
    3
    post = Post.find(1)
    tag = Tag.find(2)
    post.tags << tag unless post.tags.include?(tag)


    在Rails4中:

    1
    2
    class Post < ActiveRecord::Base
      has_and_belongs_to_many :tags, -> { uniq }

    (注意,-> { uniq }必须直接在关系名称之后,在其他参数之前)

    Rails文档


    您可以按照文档中的说明传递:uniq选项。另请注意,:uniq选项不会阻止创建重复关系,它只会确保访问者/查找方法会选择它们一次。

    如果要防止关联表中出现重复,则应创建唯一索引并处理异常。此外,validates_uniqueness_of无法按预期工作,因为您可以考虑第二个请求在第一个请求检查重复项并写入数据库之间写入数据库的情况。


    设置uniq选项:

    1
    2
    3
    4
    5
    6
    class Tag < ActiveRecord::Base
       has_and_belongs_to_many :posts , :uniq => true
    end

    class Post < ActiveRecord::Base
       has_and_belongs_to_many :tags , :uniq => true


    我更愿意调整模型并以这种方式创建类:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    class Tag < ActiveRecord::Base
       has_many :taggings
       has_many :posts, :through => :taggings
    end

    class Post < ActiveRecord::Base
       has_many :taggings
       has_many :tags, :through => :taggings
    end

    class Tagging < ActiveRecord::Base
       belongs_to :tag
       belongs_to :post
    end

    然后我将创建包装在逻辑中,以便Tag模型在已经存在的情况下被重用。我甚至可能在标签名称上加上一个唯一的约束来强制执行它。这样可以更有效地搜索任何一种方式,因为您只需使用连接表上的索引(查找特定标记的所有帖子,以及特定帖子的所有标记)。

    唯一的问题是您不能允许重命名标记,因为更改标记名称会影响该标记的所有使用。让用户删除标签并改为创建一个新标签。


    我通过创建一个修复内容的before_save过滤器来解决这个问题。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    class Post < ActiveRecord::Base
       has_and_belongs_to_many :tags
       before_save :fix_tags

       def tag_list= (tag_list)
          self.tags.clear
          tag_list.strip.split(' ').each do
            self.tags.build(:name => tag)
          end
       end  

        def fix_tags
          if self.tags.loaded?
            new_tags = []
            self.tags.each do |tag|
              if existing = Tag.find_by_name(tag.name)
                new_tags << existing
              else
                new_tags << tag
              end  
            end

            self.tags = new_tags
          end
        end

    end

    它可以稍微优化以与标签一起批量工作,也可能需要一些稍微更好的事务支持。


    对我工作

  • 在连接表上添加唯一索引
  • 覆盖关系中的<<方法

    1
    2
    3
    4
    5
    6
    has_and_belongs_to_many :groups do
      def << (group)
        group -= self if group.respond_to?(:to_a)
        super group unless include?(group)
      end
    end

  • 这真的很古老,但我想我会分享这样做的方式。

    1
    2
    3
    4
    5
    6
    7
    class Tag < ActiveRecord::Base
        has_and_belongs_to_many :posts
    end

    class Post < ActiveRecord::Base
        has_and_belongs_to_many :tags
    end

    在我需要为帖子添加标签的代码中,我执行以下操作:

    1
    2
    new_tag = Tag.find_by(name: 'cool')
    post.tag_ids = (post.tag_ids + [new_tag.id]).uniq

    这具有根据需要自动添加/删除标签的效果,或者如果是这种情况则不执行任何操作。


    提取标记名称以确保安全性。检查标记表中是否存在标记,如果不存在,则创建标记:

    1
    2
    name = params[:tag][:name]
    @new_tag = Tag.where(name: name).first_or_create

    然后检查它是否存在于此特定集合中,如果不存在则将其推送:

    1
    @taggable.tags << @new_tag unless @taggable.tags.exists?(@new_tag)

    只需在添加记录之前在控制器中添加一个检查。如果是,则不执行任何操作,如果没有,请添加新的:

    1
    2
    3
    4
    5
    6
    7
    u = current_user
    a = @article
    if u.articles.exists?(a)

    else
      u.articles << a
    end

    更多:"4.4.1.14 collection.exists?(...)"
    http://edgeguides.rubyonrails.org/association_basics.html#scopes-for-has-and-belongs-to-many


    您应该在tag:name属性上添加索引,然后在Tags#create方法中使用find_or_create方法

    文档