Rails 限制 has_many 数量的自定义验证:允许通过关联?

Rails custom validation that limits the number of has_many :through associations allowed?

我有一个带有 "Product Variant" 表单的 Rails 项目。产品变体模型称为 Variant,在 Variant 表单上,用户应该能够 select 为每个可用选项选择一个。例如,一件 T 恤可能有一件名为 "Size" 的 "Option" 带有 "Choices" 小号、中号或大号,而另一件 "Option" 名为 "Color" 带有"Choices" 红、绿、蓝。因此,创建的 Variant 是一个独特的 SKU,例如 "T-shirt — Size: Small, Color: Green." 或者如果它是具有 3 个选项而不是 2 个选项的产品,则变体每个选项需要 3 个选项,例如 "Guitar Strap - Size: Long, Fabric Color: Red, Leather Color: Brown".

我不知道如何编写只允许用户为每个选项保存一个选择的自定义验证。对于每个变体,每个选项应该只有一个选择 selected。这是一个例子。

这是我与相关协会的模型...

models/variant.rb

class Variant < ApplicationRecord    
  has_many :selections
  has_many :choices, through: :selections

  validate :one_choice_per_option

  private
    def one_choice_per_option
      # can't figure out how to do this custom validation here
    end

end

models/choice.rb

class Choice < ApplicationRecord
  has_many :variants, through: :selections

  belongs_to :option
end

models/selection.rb

class Selection < ApplicationRecord
  belongs_to :choice
  belongs_to :variant
end

models/option.rb

class Option < ApplicationRecord
  has_many :choices, dependent: :destroy

  accepts_nested_attributes_for :choices, allow_destroy: true
end

我能做的最好的事情就是让这个自定义验证在 models/variant.rb

中运行
def one_choice_per_option
  self.product.options.each do |option|
    if option.choices.count > 1
      errors.add(:choice, 'Error: select one choice for each option')
    end
  end
end

但是,通过变体形式,总共只允许一个 Choice。我想做的是允许每组选项有一个选择。

我知道这可以在 UI 中使用 Javascript 来完成,但这对于保持数据库清洁和防止用户错误是必不可少的,所以我认为它应该是 Rails 模型级别的验证。

执行此类自定义验证的 "Railsy" 方法是什么?我应该尝试对 Selection 模型进行自定义验证吗?如果是,怎么做?


更新

基于评论中的讨论。看来我需要做一些 Active Record querying 的组合才能完成这项工作。下面@sevensidedmarble 的 "EDIT 2" 更接近,但这给了我这个错误:Type Error compared with non class/module

如果我将错误的行为保存到数据库中,然后在控制台中调用 Variant.last.choices,感觉就像我越来越接近:

所以基本上,如果有多个 Selection 具有相同的 option_id,我需要做的是不允许 Variant 表格保存。 selection 不应保存,除非 option_id 对关联的 Variant 是唯一的。

我正在尝试做这样的事情:

validate :selections_must_have_unique_option

  private

    def selections_must_have_unique_option
      unless self.choices.distinct(:option_id)
        errors.add(:options, 'can only have one choice per option')
      end
    end

但该代码不起作用。它只是保存表单,就好像验证不存在一样。

像这样的东西应该可以工作:

def one_choice_per_option
      errors.add(:choice, "can't be more then one per selection") if selections.any { |selection| selection.choices.count > 1 }
end

或者,我怀疑您也可以只选择 has_one :choice,这样您的生活可能会更简单。


编辑:

根据评论中的要求,这里有一个更新我认为会完全按照要求进行:

def one_choice_per_option
      errors.add(:choice, "can't be more then one per selection") if selections.joins(:choice).where(selections: :choice).count > 3
end

我想这会得到你想要的。如果不是,我们只需要稍微弄乱 joinswhere 部分,让我知道。

编辑 2:


好的,经过再次讨论,我对要求的内容更加清楚了。很抱歉花了一些时间,如果不在您自己的应用程序中看到它就很难理解它。

这是我认为 对指定内容有效的内容。我是在 Product 的上下文中写的,但它也可以在 Option 上进行一些改动。

def one_choice_per_option
  errors.add(:options, "can't be more then one per selection") if (options.joins(:choices).group("options.id").having("COUNT(1) > 1") > 1)
end

我认为这应该可行,如果不行的话,它真的很接近您的需要。这个SQL就是基本思路。有时在这些查询上调用 .to_sql 可以帮助准确查看 activerecords 输出的内容。


编辑 3:

我在考虑你在 Variant 上的原始代码,并想出了这个:

class Variant < ApplicationRecord
  has_many :selections
  has_many :choices, through: :selections

  validate :one_choice_per_option

  private
  def one_choice_per_option
    if (choices.joins(:option).group("options.id").having("COUNT(1) > 1") > 1)
      errors.add(:choices, "can't be more then one per selection") 
    end
  end
end

它类似于唯一性约束,但 Rails 的内置 validates_uniqueness_of 无法处理此问题:所需的验证是在每个 Selection 对象上进行的,但它是可选的,并且selections table 没有 要约束的 option_id 列。

您也不能在数据库中轻易做到这一点,因为唯一索引不会跨越 table 边界。

我建议您让每个 Selection 对象查找有冲突的同级对象,并对结果使用 absence validation。像这样:

class Variant < ApplicationRecord    
  has_many :selections
  has_many :choices, through: :selections
end

class Selection < ApplicationRecord
  belongs_to :choice
  belongs_to :variant

  validates_absence_of :conflicting_selection

  protected
    def option
      choice.option
    end

    def conflicting_selection
      variant.selections.excluding(self).detect { |other| option == other.option }
    end
end

鹰眼会注意到我使用了数组方法而不是 ActiveRecord 查询。这不是避免数据库往返的俗气噱头;它确保验证在未保存的选择以及持久的选择上正确工作,这对于表单处理可能是必不可少的。我通常想写成 has_one :option, through: :choiceSelection#option 方法与此类似。

带有单元测试的示例要点 here 如果需要。