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.
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?
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:
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.
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.