为什么数字验证器不能与活动模型属性一起工作?



我使用Rails 7和Ruby 3.1,以及Shoulda匹配器进行测试,但不是活动记录,因为我不需要数据库。我想验证数字的有效性。然而,验证不起作用。看起来输入被转换成整数,而不是被验证。我不明白为什么会这样。

我的模型:

# 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

我的测试:

# 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

然而,我的测试失败了:

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.
<标题>

更新比以前更糟,因为测试工作,但代码实际上没有工作。

# 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

根本问题(或也许不是问题)是你铸字rows属性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.

为了更明显,让我们删除inclusion验证:

class Grid
include ActiveModel::Model
include ActiveModel::Attributes
attribute :rows, :integer
validates :rows, numericality: { only_integer: true }
end

然而,这并不能解决数字测试:

# 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.

在测试validate_numericality_of中,首先,期望"0.1"的记录无效,但是grid仍然有效,这就是为什么它失败的原因。

除了像您那样替换底层验证之外,还有一些其他选项:

你可以替换数字测试:

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 }

或remove typeccast:

attribute :rows

似乎你试图过度使用验证和类型转换。在我看来,唯一的问题只是一个测试,其他一切都很好。无论如何,我想出了更多的变通方法:

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

我的解决方案是将验证和类型转换划分为模型。

# 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 

规格

# 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

控制器

# 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