5

You have a model, say, Car. Some validations apply to every Car instance, all the time:

class Car
  include ActiveModel::Model

  validates :engine, :presence => true
  validates :vin,    :presence => true
end

But some validations are only relevant in specific contexts, so only certain instances should have them. You'd like to do this somewhere:

c = Car.new
c.extend HasWinterTires
c.valid?

Those validations go elsewhere, into a different module:

module HasWinterTires
  # Can't go fast with winter tires.
  validates :speed, :inclusion => { :in => 0..30 }
end

If you do this, validates will fail since it's not defined in Module. If you add include ActiveModel::Validations, that won't work either since it needs to be included on a class.

What's the right way to put validations on model instances without stuffing more things into the original class?

John Feminella
  • 303,634
  • 46
  • 339
  • 357

4 Answers4

3

There are several solutions to this problem. The best one probably depends on your particular needs. The examples below will use this simple model:

class Record
  include ActiveModel::Validations

  validates :value, presence: true

  attr_accessor :value
end

Rails 4 only

Use singleton_class

ActiveSupport::Callbacks were completely overhauled in Rails 4 and putting validations on the singleton_class will now work. This was not possible in Rails 3 due to the implementation directly referring to self.class.

record = Record.new value: 1
record.singleton_class.validates :value, numericality: {greater_than: 1_000_000}
record.valid? || record.errors.full_messages
# => ["Value must be greater than 1000000"]

Rails 3 and 4

In Rails 3, validations are also implemented using ActiveSupport::Callbacks. Callbacks exist on the class, and while the callbacks themselves are accessed on a class attribute which can be overridden at the instance-level, taking advantage of that requires writing some very implementation-dependent glue code. Additionally, the "validates" and "validate" methods are class methods, so you basically you need a class.

Use subclasses

This is probably the best solution in Rails 3 unless you need composability. You will inherit the base validations from the superclass.

class BigRecord < Record
  validates :value, numericality: {greater_than: 1_000_000}
end

record = BigRecord.new value: 1
record.valid? || record.errors.full_messages
# => ["Value must be greater than 1000000"]

For ActiveRecord objects, there are several ways to "cast" a superclass object to a subclass. subclass_record = record.becomes(subclass) is one way.

Note that this will also preserve the class methods validators and validators_on(attribute). The SimpleForm gem, for example, uses these to test for the existence of a PresenceValidator to add "required" CSS classes to the appropriate form fields.

Use validation contexts

Validation contexts are one of the "official" Rails ways to have different validations for objects of the same class. Unfortunately, validation can only occur in a single context.

class Record
  include ActiveModel::Validations

  validates :value, presence: true

  attr_accessor :value

  # This can also be put into a module (or Concern) and included
  with_options :on => :big_record do |model|
    model.validates :value, numericality: {greater_than: 1_000_000}
  end
end

record = Record.new value: 1
record.valid?(:big_record) || record.errors.full_messages
# => ["Value must be greater than 1000000"]

# alternatively, e.g., if passing to other code that won't supply a context:
record.define_singleton_method(:valid?) { super(:big_record) }
record.valid? || record.errors.full_messages
# => ["Value must be greater than 1000000"]

Use #validates_with instance method

#validates_with is one of the only instance methods available for validation. It accepts one or more validator classes and any options, which will be passed to all classes. It will immediately instantiate the class(es) and pass the record to them, so it needs to be run from within a call to #valid?.

module Record::BigValidations
  def valid?(context=nil)
    super.tap do
      # (must validate after super, which calls errors.clear)
      validates_with ActiveModel::Validations::NumericalityValidator,
        :greater_than => 1_000_000,
        :attributes => [:value]
    end && errors.empty?
  end
end

record = Record.new value: 1
record.extend Record::BigValidations
record.valid? || record.errors.full_messages
# => ["Value must be greater than 1000000"]

For Rails 3, this is probably your best bet if you need composition and have so many combinations that subclasses are impractical. You can extend with multiple modules.

Use SimpleDelegator

big_record_delegator = Class.new(SimpleDelegator) do
  include ActiveModel::Validations

  validates :value, numericality: {greater_than: 1_000_000}

  def valid?(context=nil)
    return true if __getobj__.valid?(context) && super
    # merge errors
    __getobj__.errors.each do |key, error|
      errors.add(key, error) unless errors.added?(key, error)
    end
    false
  end

  # required for anonymous classes
  def self.model_name
    Record.model_name
  end
end

record = Record.new value: 1
big_record = big_record_delegator.new(record)
big_record.valid? || big_record.errors.full_messages
# => ["Value must be greater than 1000000"]

I used an anonymous class here to give an example of using a "disposable" class. If you had dynamic enough validations such that well-defined subclasses were impractical, but you still wanted to use the "validate/validates" class macros, you could create an anonymous class using Class.new.

One thing you probably don't want to do is create anonymous subclasses of the original class (in these examples, the Record class), as they will be added to the superclass's DescendantTracker, and for long-lived code, could present a problem for garbage collection.

Steve
  • 6,618
  • 3
  • 44
  • 42
  • Not sure about Rails 4, but in Rails 5 defining the validations on `singleton_class` still pollutes the base class. – Marcin Kołodziej Feb 24 '20 at 16:06
  • @MarcinKołodziej - Can you be more specific? I tested the singleton_class solution in Rails 4.2.10, 5.0.7, 5.1.6, and 5.2.0, and I don't see any pollution of the base class. – Steve Apr 17 '20 at 12:32
  • here's a quick gist: https://gist.github.com/MmKolodziej/39be4f55ce78cdf58df47671680b1367 – Marcin Kołodziej Apr 18 '20 at 13:23
  • 1
    Thanks, @MarcinKołodziej, I see it now. It modifies `::validators`, which also affects `::validators_on()`. I had been looking at `::_validate_callbacks` and running the validations themselves. – Steve Apr 20 '20 at 15:05
0

You could perform the validation on the Car, if the Car extends the HasWinterTires module... For example:

class Car < ActiveRecord::Base

  ...

  validates :speed, :inclusion => { :in => 0..30 }, :if => lambda { self.singleton_class.ancestors.includes?(HasWinterTires) }

end

I think you can just do self.is_a?(HasWinterTires) instead of the singleton_class.ancestors.include?(HasWinterTires), but I haven't tested it.

CDub
  • 13,146
  • 4
  • 51
  • 68
  • Now I bloated my model class for a situational use case, though. That's the situation I'm trying to avoid ("What's the right way to put validations on model instances **without stuffing more things into the original class**?"). – John Feminella Jan 03 '14 at 18:51
  • I guess I don't understand why you are so concerned about "stuffing more things" into your `Car` class... Instances of the car care about whether they have winter tires, the winter tires don't care about what car they're on... – CDub Jan 03 '14 at 19:14
  • I think an explanation of all the different ways that having slim models in Rails is a Good Thing (TM) would be a little long for the comment box. But avoiding bloat in Rails models is usually seen as a positive, and I'd like to avoid it here too. – John Feminella Jan 03 '14 at 19:17
  • I agree that it's a good thing, but for what you're trying to do, I believe this is one of the best ways to do what you're trying to do, if for no other reason than this validation belongs on the parent object. Plus if you're worried about a sentence-worth of extra characters "bloating" your model, why have the validation at all? – CDub Jan 03 '14 at 19:19
  • 1
    Imagine that there are N different kinds of ways that the cars could be configured, each with their own rules about maximum speed, power usage, weight loads, et cetera -- and they all only apply in very specific cases. Would you still agree that all of that goes on the same model? – John Feminella Jan 03 '14 at 19:20
  • Assuming that you have fields for those configurations *somewhere*, then there will always be various multiples of N different kinds of ways a car can be configured, resulting in yes, I still believe that it goes on the model for the car. From a real world perspective, why do the tires in my garage care about what car they're on and when? They don't... But the car sure does, thus why my winter tires are on the shelf right now. :) – CDub Jan 03 '14 at 19:22
  • In your question as well, are you going to have modules for `Speed`, `PowerUsage`, `WeightLoads`, etc.? – CDub Jan 03 '14 at 19:23
0

Have you thought about using a Concern? So something like this should work.

module HasWinterTires
  extend ActiveSupport::Concern

  module ClassMethods
    validates :speed, :inclusion => { :in => 0..30 }
  end
end

The concern won't care that it itself has no idea what validates does.

Then I believe that you can just do instance.extend(HasWinterTires), and it should all work.

I'm writing this out from memory, so let me know if you have any issues.

Bryce
  • 2,802
  • 1
  • 21
  • 46
  • That won't work for the same reason I mentioned above: when Ruby reads the `validates :speed ...` line, it will fail because `validates` is not a method that `HasWinterTires::ClassMethods` knows about. – John Feminella Jan 03 '14 at 18:59
0

You likely want something like this, which is very similar to your original attempt:

module HasWinterTires
  def self.included(base)
    base.class_eval do
      validates :speed, :inclusion => { :in => 0..30 }
    end
  end
end

Then the behavior should work as expected in your example, though you need to use include instead of extend on HasWinterTires.

LAW
  • 1,099
  • 3
  • 12
  • 20