Skip to content

Commit

Permalink
Add validation_helpers model plugin, which adds instance level valida…
Browse files Browse the repository at this point in the history
…tion support similar to previously standard validations, with a different API

This plugin should satisfy most validation needs.  All previously
standard validations were ported except for acceptance_of and
confirmation_of, both of which are anti-patterns, as they encourage
error checking in the model that should be done in the controller.
Model validations should deal with the errors in the model's data,
not whether a user checked a box or put an identical value in a
confirmation field.

validates_each was not ported as it is much easier to just write your
own validation code if a standard validation doesn't handle it:

  # Old class level validation
  validates_each(:date) do |o,a,v|
    o.errors[a] << '...' unless v > Date.today
  end

  # New instance level validation
  def validate
    errors[:date] << '...' unless date > Date.today
  end

The other validations were mostly renamed:

  # Old class level validations
  validates_format_of :col, :with=>/.../
  validates_length_of :col, :maximum=>5
  validates_length_of :col, :minimum=>3
  validates_length_of :col, :is=>4
  validates_length_of :col, :within=>3..5
  validates_not_string :col
  validates_numericality_of :col
  validates_numericality_of :col, :only_integer=>true
  validates_presence_of :col
  validates_inclusion_of :col, :in=>[3, 4, 5]
  validates_uniqueness_of :col, :col2
  validates_uniqueness_of([:col, :col2])

  # New instance level validations
  def validate
    validates_format /.../, :col
    validates_max_length 5, :col
    validates_min_length 3, :col
    validates_exact_length 4, :col
    validates_length_range 3..5, :col
    validates_not_string :col
    validates_numeric :col
    validates_integer :col
    validates_presence :col
    validates_includes([3,4,5], :col)
    validates_unique :col, :col2
    validates_unique([:col, :col2])
  end

Another change made is to specify the same type of validation on
multiple attributes, you must use an array:

  # Old
  validates_length_of :name, :password, :within=>3..5

  # New
  def validate
    validates_length_range 3..5, [:name, :password]
  end

The :message, :allow_blank, :allow_missing, and :allow_nil options
are still respected.  The :tag option is not needed as instance level
validations work with code reloading without workarounds. The :if
option is also not needed for instance level validations:

  # Old
  validates_presence_of :name, :if=>:new?
  validates_presence_of :pass, :if=>{flag > 3}

  # New
  def validate
    validates_presence(:name) if new?
    validates_presence(:pass) if flag > 3
  end

validates_unique is a little different than the other new
validations.  It doesn't accept the :allow_* options, and you can
specify multiple attributes instead of using an array.  If you
use an array, it is intepreted that you want the combination of
values unique, instead of each value unique, which is how it
operated previously.  The new uniqueness logic is also much
simpler and hopefully more robust, though you should still have
a unique database index for integrity purposes.

The validation_helpers plugin is half the size of the
validation_class_methods plugin, and has better specs, IMO.

This commit also removes some duplicative code from the
validation_class_methods specs.
  • Loading branch information
jeremyevans committed Mar 27, 2009
1 parent 0c9fa7c commit 9b429cc
Show file tree
Hide file tree
Showing 4 changed files with 432 additions and 75 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
=== HEAD

* Add validation_helpers model plugin, which adds instance level validation support similar to previously standard validations, with a different API (jeremyevans)

* Split multi_insert into 2 methods with separate APIs, multi_insert for hashes, import for arrays of columns and values (jeremyevans)

* Deprecate Dataset#transform and Model.serialize, and model serialization plugin (jeremyevans)
Expand Down
149 changes: 149 additions & 0 deletions lib/sequel/plugins/validation_helpers.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
module Sequel
module Plugins
module ValidationHelpers
# ValidationHelpers contains instance method equivalents for most of the previous
# default validations. The names and APIs have changed, though.
#
# The validates_unique validation has a unique API, but the other validations have
# the API explained here:
#
# Arguments:
# * atts - Single attribute symbol or an array of attribute symbols specifying the
# attribute(s) to validate.
# Options:
# * :allow_blank - Whether to skip the validation if the value is blank. You should
# make sure all objects respond to blank if you use this option, which you can do by
# requiring 'sequel/extensions/blank'
# * :allow_missing - Whether to skip the validation if the attribute isn't a key in the
# values hash. This is different from allow_nil, because Sequel only sends the attributes
# in the values when doing an insert or update. If the attribute is not present, Sequel
# doesn't specify it, so the database will use the table's default value. This is different
# from having an attribute in values with a value of nil, which Sequel will send as NULL.
# If your database table has a non NULL default, this may be a good option to use. You
# don't want to use allow_nil, because if the attribute is in values but has a value nil,
# Sequel will attempt to insert a NULL value into the database, instead of using the
# database's default.
# * :allow_nil - Whether to skip the validation if the value is nil.
# * :message - The message to use
module InstanceMethods
# Check that the attribute values are the given exact length.
def validates_exact_length(exact, atts, opts={})
validatable_attributes(atts, opts){|a,v,m| (m || "is not #{exact} characters") unless v && v.length == exact}
end

# Check the string representation of the attribute value(s) against the regular expression with.
def validates_format(with, atts, opts={})
validatable_attributes(atts, opts){|a,v,m| (m || 'is invalid') unless v.to_s =~ with}
end

# Check attribute value(s) is included in the given set.
def validates_includes(set, atts, opts={})
validatable_attributes(atts, opts){|a,v,m| (m || "is not in range or set: #{set.inspect}") unless set.include?(v)}
end

# Check attribute value(s) string representation is a valid integer.
def validates_integer(atts, opts={})
validatable_attributes(atts, opts) do |a,v,m|
begin
Kernel.Integer(v.to_s)
nil
rescue
m || 'is not a number'
end
end
end

# Check that the attribute values length is in the specified range.
def validates_length_range(range, atts, opts={})
validatable_attributes(atts, opts){|a,v,m| (m || "is outside the allowed range") unless v && range.include?(v.length)}
end

# Check that the attribute values are not longer than the given max length.
def validates_max_length(max, atts, opts={})
validatable_attributes(atts, opts){|a,v,m| (m || "is longer than #{max} characters") unless v && v.length <= max}
end

# Check that the attribute values are not shorter than the given min length.
def validates_min_length(min, atts, opts={})
validatable_attributes(atts, opts){|a,v,m| (m || "is shorter than #{min} characters") unless v && v.length >= min}
end

# Check that the attribute value(s) is not a string. This is generally useful
# in conjunction with raise_on_typecast_failure = false, where you are
# passing in string values for non-string attributes (such as numbers and dates).
# If typecasting fails (invalid number or date), the value of the attribute will
# be a string in an invalid format, and if typecasting succeeds, the value will
# not be a string.
def validates_not_string(atts, opts={})
validatable_attributes(atts, opts) do |a,v,m|
next unless v.is_a?(String)
next m if m
(sch = db_schema[a] and typ = sch[:type]) ? "is not a valid #{typ}" : "is a string"
end
end

# Check attribute value(s) string representation is a valid float.
def validates_numeric(atts, opts={})
validatable_attributes(atts, opts) do |a,v,m|
begin
Kernel.Float(v.to_s)
nil
rescue
m || 'is not a number'
end
end
end

# Check attribute value(s) is not considered blank by the database, but allow false values.
def validates_presence(atts, opts={})
validatable_attributes(atts, opts){|a,v,m| (m || "is not present") if model.db.send(:blank_object?, v) && v != false}
end

# Checks that there are no duplicate values in the database for the given
# attributes. Pass an array of fields instead of multiple
# fields to specify that the combination of fields must be unique,
# instead of that each field should have a unique value.
#
# This means that the code:
# validates_unique([:column1, :column2])
# validates the grouping of column1 and column2 while
# validates_unique(:column1, :column2)
# validates them separately.
#
# You should also add a unique index in the
# database, as this suffers from a fairly obvious race condition.
#
# This validation does not respect the :allow_* options that the other validations accept,
# since it can deals with multiple attributes at once.
#
# Possible Options:
# * :message - The message to use (default: 'is already taken')
def validates_unique(*atts)
message = (atts.pop[:message] if atts.last.is_a?(Hash)) || 'is already taken'
atts.each do |a|
ds = model.filter(Array(a).map{|x| [x, send(x)]})
errors[a] << message unless (new? ? ds : ds.exclude(pk_hash)).count == 0
end
end

private

# Skip validating any attribute that matches one of the allow_* options.
# Otherwise, yield the attribute, value, and passed option :message to
# the block. If the block returns anything except nil or false, add it as
# an error message for that attributes.
def validatable_attributes(atts, opts)
Array(atts).each do |a|
next if opts[:allow_missing] && !values.has_key?(a)
v = send(a)
next if opts[:allow_nil] && value.nil?
next if opts[:allow_blank] && value.respond_to?(:blank?) && value.blank?
if message = yield(a, v, opts[:message])
errors[a] << message
end
end
end
end
end
end
end
75 changes: 0 additions & 75 deletions spec/extensions/validation_class_methods_spec.rb
Original file line number Diff line number Diff line change
@@ -1,80 +1,5 @@
require File.join(File.dirname(__FILE__), "spec_helper")

describe Sequel::Model::Errors do
before do
@errors = Sequel::Model::Errors.new
end

specify "should be clearable using #clear" do
@errors.add(:a, 'b')
@errors.should == {:a=>['b']}
@errors.clear
@errors.should == {}
end

specify "should be empty if no errors are added" do
@errors.should be_empty
@errors[:blah] << "blah"
@errors.should_not be_empty
end

specify "should return errors for a specific attribute using #on or #[]" do
@errors[:blah].should == []
@errors.on(:blah).should == []

@errors[:blah] << 'blah'
@errors[:blah].should == ['blah']
@errors.on(:blah).should == ['blah']

@errors[:bleu].should == []
@errors.on(:bleu).should == []
end

specify "should accept errors using #[] << or #add" do
@errors[:blah] << 'blah'
@errors[:blah].should == ['blah']

@errors.add :blah, 'zzzz'
@errors[:blah].should == ['blah', 'zzzz']
end

specify "should return full messages using #full_messages" do
@errors.full_messages.should == []

@errors[:blow] << 'blieuh'
@errors[:blow] << 'blich'
@errors[:blay] << 'bliu'
msgs = @errors.full_messages
msgs.size.should == 3
msgs.should include('blow blieuh', 'blow blich', 'blay bliu')
end

specify "should return the number of error messages using #count" do
@errors.count.should == 0
@errors.add(:a, 'b')
@errors.count.should == 1
@errors.add(:a, 'c')
@errors.count.should == 2
@errors.add(:b, 'c')
@errors.count.should == 3
end

specify "should return the array of error messages for a given attribute using #on" do
@errors.add(:a, 'b')
@errors.on(:a).should == ['b']
@errors.add(:a, 'c')
@errors.on(:a).should == ['b', 'c']
@errors.add(:b, 'c')
@errors.on(:a).should == ['b', 'c']
end

specify "should return nil if there are no error messages for a given attribute using #on" do
@errors.on(:a).should == nil
@errors.add(:b, 'b')
@errors.on(:a).should == nil
end
end

describe Sequel::Model do
before do
@c = Class.new(Sequel::Model) do
Expand Down
Loading

0 comments on commit 9b429cc

Please sign in to comment.