Why is numericality validator not working with Active Model Attributes?

756 Views Asked by At

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
2

There are 2 best solutions below

0
Chiara Ani On BEST ANSWER

My solution was dividing validations and typecasts into models.

# app/models/grid.rb
class Grid
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :rows, :integer
end
# app/models/grid_data.rb
class GridData
  include ActiveModel::Model
  include ActiveModel::Attributes
  
  Grid.attribute_names.each { |name| attribute name }

  validates(*attribute_names, presence: true)
  validates :rows, numericality: { only_integer: true, in: 1..50 }
end 

Specs

# spec/models
RSpec.describe Grid, type: :model do
  let(:grid) { described_class.new(**attributes) }

  describe 'type cast' do
    let(:attributes) { default(rows: '2') }

    it 'parses string valid arguments to integer or float' do
      expect(grid.rows).to eq 2
    end
  end
end
RSpec.describe GridData, type: :model do
  it 'has same attributes as Grid model' do
    expect(described_class.attribute_names).to eq Grid.attribute_names
  end

  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)
        validate = validate.only_integer if type == :integer
        expect(subject).to validate
      end
        
      it do
        expect(subject).to validate_inclusion_of(field)
          .in_range(range)
          .with_message("must be in #{range}")
      end
    end

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

Controller

# app/controller/grids_controller.rb
class GridsController < ApplicationController
  def create
    @grid_data = GridData.new(**grid_params)
    
    if @grid_data.valid?
      play
    else 
      render :new, status: :unprocessable_entity
    end 
  end

  private
      
  def grid_params
    params.require(:grid_data).permit(*Grid.attribute_names)
  end     
      
  def play
    render :play, status: :created
    Grid.new(**@grid_data.attributes).play
  end
end
2
Alex On

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 workarounds:

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