Home > Enterprise >  Why is numericality validator not working with Active Model Attributes?
Why is numericality validator not working with Active Model Attributes?

Time:07-15

I'm using Rails 7 and Ruby 3.1, and Shoulda Matchers for tests, but not Active Record, for I do not need a database. I want to validate numericality. However, validations do not work. It looks like input is transformed into integer, instead of being validated. I do not understand why that happens.

My model:

# app/models/grid.rb

class Grid
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :rows, :integer
  validates :rows, inclusion: { in: 1..50 }, numericality: { only_integer: true }
  
  # Some other code...
end

My test:

# spec/models/grid_spec.rb 

RSpec.describe Grid, type: :model do   
  describe 'validations' do                      
    shared_examples 'validates' do |field, range|
      it { is_expected.to validate_numericality_of(field).only_integer }
      it { is_expected.to validate_inclusion_of(field).in_range(range) }
    end

    include_examples 'validates', 'rows', 1..50
  end

  # Some other tests...
end

Nonetheless, my test fails:

Grid validations is expected to validate that :rows looks like an integer
     Failure/Error: it { is_expected.to validate_numericality_of(field).only_integer }
     
       Expected Grid to validate that :rows looks like an integer, but this
       could not be proved.
         After setting :rows to ‹"0.1"› -- which was read back as ‹0› -- the
         matcher expected the Grid to be invalid and to produce the validation
         error "must be an integer" on :rows. The record was indeed invalid,
         but it produced these validation errors instead:
     
         * rows: ["is not included in the list"]
     
         As indicated in the message above, :rows seems to be changing certain
         values as they are set, and this could have something to do with why
         this test is failing. If you've overridden the writer method for this
         attribute, then you may need to change it to make this test pass, or
         do something else entirely.

Update

Worse than before, because tests are working but code is not actually working.

# app/models/grid.rb

class Grid
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :rows, :integer
  validates :rows, presence: true, numericality: { only_integer: true, in: 1..50 }

  # Some other code...
end
# spec/models/grid_spec.rb 

RSpec.describe Grid, type: :model do   
  describe 'validations' do                      
    shared_examples 'validates' do |field, type, range|
      it { is_expected.to validate_presence_of(field) } 
      it do                                                                                    
        validate = validate_numericality_of(field)
          .is_greater_than_or_equal_to(range.min)
          .is_less_than_or_equal_to(range.max)
          .with_message("must be in #{range}")              
  
        is_expected.to type == :integer ? validate.only_integer : validate
      end
    end

    include_examples 'validates', 'rows', :integer, 1..50
  end

  # Some other tests...
end

CodePudding user response:

The underlying problem (or maybe not a problem) is that you're typecasting rows attribute to integer.

>> g = Grid.new(rows: "num"); g.validate
>> g.errors.as_json
=> {:rows=>["is not included in the list"]}

# NOTE: only inclusion errors shows up, making numericality test fail.

To make it more obvious, let's remove inclusion validation:

class Grid
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :rows, :integer
  validates :rows, numericality: { only_integer: true }
end

Still, this does not fix numericality test:

# NOTE: still valid
>> Grid.new(rows: "I'm number, trust me").valid?
=> true

# NOTE: because `rows` is typecasted to integer, it will
#       return `0` which is numerical.

>> Grid.new(rows: "I'm number, trust me").rows
=> 0

>> Grid.new(rows: 0.1).rows
=> 0

# NOTE: keep in mind, this is the current behavior, which
#       might be unexpected.

In the test validate_numericality_of, first of all, expects an invalid record with "0.1", but grid is still valid, which is why it fails.

Besides replacing the underlying validations, like you did, there are a few other options:

You could replace numericality test:

it { expect(Grid.new(rows: "number").valid?).to eq true }
it { expect(Grid.new(rows: "number").rows).to eq 0 }

# give it something not typecastable, like a class.
it { expect(Grid.new(rows: Grid).valid?).to eq false }

Or remove typecast:

attribute :rows

Update

Seems like you're trying to overdo it with validations and typecasting. From what I can see the only issue is just one test, everything else works fine. Anyway, I've came up with a few more workaraounds:

class Grid
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :rows, :integer
  validates :rows, inclusion: 1..50, numericality: { only_integer: true }

  def rows= arg
    # NOTE: you might want to raise an error instead,
    #       because this validation will go away if you run
    #       validations again.
    errors.add(:rows, "invalid") if (/\d / !~ arg.to_s)
    super
  end
end
class Grid
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :rows, :integer
  validates :rows, inclusion: 1..50, numericality: { only_integer: true }

  validate :validate_rows_before_type_cast
  def validate_rows_before_type_cast
    rows = @attributes.values_before_type_cast["rows"]
    errors.add(:rows, :not_a_number) if rows.is_a?(String) && rows !~ /^\d $/
  end
end
class Grid
  include ActiveModel::Model
  include ActiveModel::Attributes
  include ActiveRecord::AttributeMethods::BeforeTypeCast

  attribute :rows, :integer
  validates :rows, inclusion: 1..50

  # NOTE: this does show "Rows before type cast is not a number"
  #       maybe you'd want to customize the error message.
  validates :rows_before_type_cast, numericality: { only_integer: true }
end

https://api.rubyonrails.org/classes/ActiveRecord/AttributeMethods/BeforeTypeCast.html

  • Related