This project is archived and is in readonly mode.

#2630 ✓committed
Jeff Dean

Adding a validates_with method

Reported by Jeff Dean | May 10th, 2009 @ 04:56 AM | in 3.0.2

This patch adds a method called validates_with that takes a class, or a number of classes, and allows those classes to add errors to the record.

Example


class Person < ActiveRecord::Base
  validates_with MyValidator
end

class MyValidator < ActiveRecord::Validator
  def validate
    if some_complex_logic
      record.errors.add_to_base("This record is invalid")
    end
  end
  
  private
    def some_complex_logic
      # ...
    end
end

The current set of validations focus on data-integrity, and this patch adds support for more domain-specific validations, akin to the Specification pattern described in "Domain Driven Design" (although only fulfilling one function of the specification pattern).

Use Cases

I wrote this to help de-clutter my model code of complex, multi-model validations and to allow for easier validation reuse. For example, let's say you have a setup like Highrise, where you charge users for the number of Contacts they create, and enforce limits based on their plan. Currently you might add a validation like so:


class Person < ActiveRecord::Base
  belongs_to :account
  validate :cannot_exceed_max_allowed_people

  private
  def cannot_exceed_max_allowed_people
    if account.people.count >= account.max_allowed_people
      errors.add_to_base("You have exceeded...")
    end
  end
end

In the apps I work on, these validations have started to increase both in number and complexity, and in some of my models I have more validation code than anything else. So I've started to move these validations to separate classes for easier testability (fewer things to stub) and readability.


class Person < ActiveRecord::Base
  belongs_to :account
  validate :cannot_exceed_max_allowed_people

  private
  def cannot_exceed_max_allowed_people
    PersonAccountValidator.new(self).validate
  end
end

class PersonAccountValidator
  attr_reader :record
  def initialize(record)
    @record = record
  end
  
  def validate
    if record.account.people.count >= record.account.max_allowed_people
      record.errors.add_to_base("You have exceeded...")
    end
  end
end

I can now test the public validate method on the class on it's own, and stub out the complex validations when I need to by just creating a fake PersonAccountValidator class.

Over time these custom validator classes grow and have a lot in common, and can be DRY'ed up by using a common base class that looks like this:


class Validator
  attr_reader :record

  def initialize(record)
    @record = record
  end

  def validate
    raise "You must override this method"
  end
end

In addition to just de-cluttering validations from models, this allows better support for:

  • Shared validations - let's say that both Person and Organization count as a contact, and each needs to be validated against the Account's max_contact limit. Both classes could reuse the same validation class
  • Pluggable validations - if your app has per-installation validations based on varying regulations (like an app that's deployed to several different states, and each has different tax laws) you could move validations to a gem
  • Validation plugins - This makes it easy for gem/plugin authors to create custom validations without having to re-open validations at all. Since the non-standard configuration options get passed in, plugin authors can document their api to allow users to pass in custom attributes, like the name of a field:

class Article < ActiveRecord::Base
  validates_with SwearWordValidator, :columns => [:content, :title]
end

This patch just adds:

  • The ActiveRecord::Validator base class
  • The validates_with method
  • Tests
  • RDoc
  • An update to the guides

Comments and changes to this ticket

  • Pratik

    Pratik May 16th, 2009 @ 04:57 PM

    • Tag changed from active_record, doc, patch, validations to active_record, patch, validations
  • CancelProfileIsBroken

    CancelProfileIsBroken August 6th, 2009 @ 02:40 PM

    • Tag changed from active_record, patch, validations to active_record, bugmash, patch, validations
  • Derander

    Derander August 8th, 2009 @ 02:59 AM

    +1

    This would be useful in some of my applications. We have a huge number of models that all use essentially the same sets of validations, which are currently wrapped into modules. This would allow us to extract the "should I run this validation" logic into the model which is being validation more easily.

    I've read the source completely, and it seems reasonable and consistent with the way the other validations are implemented.

    The tests pass on 2-3-stable

  • Josh Sharpe

    Josh Sharpe August 8th, 2009 @ 03:04 AM

    Cool idea. It would make sense for Observers and Validators to function the same way, since they both have the same idea of moving functionality out of the base class... As it stands now, Observers have to be explicitly defined in environment.rb for them to function. Based on the description in this post, this doesn't seem to be the case with Validators.

    See Configuration here:

    http://api.rubyonrails.org/classes/ActiveRecord/Observer.html

  • Matt Duncan

    Matt Duncan August 8th, 2009 @ 03:05 AM

    +1

    Pluggable validations and validation plugins would be really nice.

    Passes all tests on 2-3-stable.

  • Michael Koziarski

    Michael Koziarski August 8th, 2009 @ 03:35 AM

    • Assigned user set to “Michael Koziarski”
    • Milestone cleared.

    Hey Jeff,

    I like this but only for 3.0 (bit too new a feature for a point release).

    Can you rebase it for master and re-upload. I'll apply it after that.

    Josh, the only reason observers need to be defined in env.rb is that they're 'magic' so CustomerObserver automatically observes 'Customer'. Given we have a declaration here, it's fine.

  • Jeff Dean

    Jeff Dean August 9th, 2009 @ 08:31 AM

    Thanks! I've rebased from master and uploaded the new patch.

    There are 3 things to note about this patch:

    1. It was a non-trivial move, since validations are now in ActiveModel. I moved the code and tests to match the style of ActiveModel.

    2. ActiveModel tests seem to cover the ability to send validations based on a certain key (:create, :update), and ActiveRecord tests seem to cover the fact that they send the correct key to the validations. In the original patch I put tests in to test this explicitly, but in this patch I removed the 2 test methods because they seemed redundant.

    3. The validation guide is no longer up to date with the new syntax for errors.add_to_base => errors[:base] etc... I can fix the guide and add validates_with documentation, but I'll send that as a separate patch to the guides project. I've adjusted all of the RDoc comments to reflect the new style of validations.

    Also, I had some mysterious test breakages on ActiveModel in master. CI was broken for other reasons, so can't say whether it was local or not. After looking into both of the failures, I can't imagine how any side effect of this patch would affect these tests, but since they were red for me to begin with, I can't say for sure. Here's the test output just for the record:

      1) Failure:
    test_polymorphic_assignment_with_primary_key_updates_foreign_id_field_for_new_and_saved_records(BelongsToAssociationsTest)
        [./test/cases/associations/belongs_to_associations_test.rb:417:in `test_polymorphic_assignment_with_primary_key_updates_foreign_id_field_for_new_and_saved_records'
         ./test/cases/../../lib/../../activemodel/lib/../../activesupport/lib/active_support/testing/setup_and_teardown.rb:62:in `__send__'
         ./test/cases/../../lib/../../activemodel/lib/../../activesupport/lib/active_support/testing/setup_and_teardown.rb:62:in `run']:
    <nil> expected but was
    <"">.
    
      2) Failure:
    test_schema_dump_keeps_id_column_when_id_is_false_and_id_column_added(SchemaDumperTest)
        [./test/cases/schema_dumper_test.rb:214:in `test_schema_dump_keeps_id_column_when_id_is_false_and_id_column_added'
         ./test/cases/../../lib/../../activemodel/lib/../../activesupport/lib/active_support/testing/setup_and_teardown.rb:62:in `__send__'
         ./test/cases/../../lib/../../activemodel/lib/../../activesupport/lib/active_support/testing/setup_and_teardown.rb:62:in `run']:
    non-primary key id column not preserved.
    <"    t.string \"id\",   :default => \"\", :null => false"> expected to be =~
    </t.string[[:space:]]+"id",[[:space:]]+:null => false$/>.
    
  • Jeff Dean

    Jeff Dean August 9th, 2009 @ 08:50 AM

    @josh - I based this patch off of the existing callback code, which allows you to do:

    class Foo < ActiveRecord::Base
      after_save MyCallbackClass.new
    end
    

    That code only exists, as far as I know, to support observers. It has the pleasant side effect that you can move complex callback code to a different class, and I use it all the time outside of observers.

    Then there's another layer of observers which hooks these external classes up automatically. I've never felt the need to go to that level of abstraction with validations, so I didn't add it to this feature. At the very least, I see them as separate features.

  • Rizwan Reza

    Rizwan Reza August 9th, 2009 @ 03:58 PM

    verified

    +1 This patch works cleanly on master. Safe to commit.

  • Elad Meidar

    Elad Meidar August 9th, 2009 @ 08:57 PM

    +1 verified and applied on 2-3-stable and master. +1 on idea too, i have been doing those dependent validations for a while too, trying to find a better way.. good job!

  • Josh Nichols

    Josh Nichols August 10th, 2009 @ 04:21 AM

    +1, great idea which would make it a lot easier to build re-usable and testable validations.

    Also verified it applies to master cleanly, and the tests pass.

  • Repository

    Repository August 10th, 2009 @ 06:48 AM

    • State changed from “new” to “committed”

    (from [22f339825329e2d4463a4130e9fa68baf9d27eb6]) Introduce validates_with to encapsulate attribute validations in a class.

    [#2630 state:committed]

    Signed-off-by: Jeremy Kemper jeremy@bitsweat.net
    http://github.com/rails/rails/commit/22f339825329e2d4463a4130e9fa68...

  • Jeremy Kemper

    Jeremy Kemper August 10th, 2009 @ 06:48 AM

    • Assigned user changed from “Michael Koziarski” to “josh”
    • Tag changed from active_record, bugmash, patch, validations to active_record, patch, validations
  • Jeremy Kemper

    Jeremy Kemper October 15th, 2010 @ 11:01 PM

    • Milestone set to 3.0.2
    • Importance changed from “” to “”

Create your profile

Help contribute to this project by taking a few moments to create your personal profile. Create your profile »

<h2 style="font-size: 14px">Tickets have moved to Github</h2>

The new ticket tracker is available at <a href="https://github.com/rails/rails/issues">https://github.com/rails/rails/issues</a>

Referenced by

Pages