Search code examples
ruby-on-railsformsvalidationactiverecordsimple-form

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


I have a Rails project with a "Product Variant" form. The Product variant model is called Variant, and on the Variant form users should be able to select one choice for each option available. For instance a T-Shirt might have an "Option" called "Size" with the "Choices" small, medium, or large, and another "Option" called one "Color" with the "Choices" red, green, blue. Thus, the Variant created is a unique SKU such as a "T-shirt — Size: Small, Color: Green." Or if it were a product that had 3 options instead of 2, the variant would require 3 choices per option, such as "Guitar Strap - Size: Long, Fabric Color: Red, Leather Color: Brown".

I can't figure out how to write a custom validation that only allows the user to save one choice per option. Each option should only have one choice selected for each variant. Here's an illustration.

need a validation that prevents this

correct outcome

Here are my models with the relevant associations...

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

The best I've managed to do is get this custom validation working on in 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

But that only allows one Choice total through the variant form. What I want to do is allow one choice for each set of options.

I know that this could be done with Javascript in the UI, but this is essential to keeping the database clean and preventing a user error, so I believe it should be a Rails validation at the model level.

What is a "Railsy" way to do this type of custom validation? Should I be trying to do a custom validation on the Selection model? If so, how?


UPDATE

Based on the discussion in the comments. It seems that I need to do some combination of Active Record querying to make this work. @sevensidedmarble's "EDIT 2" below is closer but that is giving me this error: Type Error compared with non class/module

If I save the wrong behavior to the database and then call Variant.last.choices in the console it feels like I'm getting closer:

illustrating the form behavior I need to prevent with a validation

showing the console response

So essentially, what I need to do is not allow the Variant form to save if there is more than one Selection with the same option_id. A selection shouldn't save unless the option_id is unique to the associated Variant.

Something like this is what I'm trying to do:

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

But that code doesn't work. It just saves the form as if the validation weren't there.


Solution

  • It's analogous to a uniqueness constraint, but Rails's built-in validates_uniqueness_of can't handle this: the validation required is on each Selection object, but it's by option, and the selections table doesn't have an option_id column to constrain.

    You can't easily do it in the database, either, because unique indexes don't cross table boundaries.

    I suggest you have each Selection object look for a conflicting sibling, and use an absence validation on the result. Something like this:

    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
    

    Eagle eyes will notice that I've used array methods rather than ActiveRecord queries. This isn't a cheesy gimmick to avoid a database round-trip; it ensures that the validation works correctly on unsaved selections as well as those persisted, which might be essential for form processing. That Selection#option method, which I'd normally want to write as has_one :option, through: :choice, is in a similar vein.

    Sample gist with unit tests here if you need it.