在 PORO 中实施验证
Implementing validations in PORO
我正在尝试在 Ruby 中实施自己的验证以供练习。
这里有一个 class Item
有 2 个验证,我需要在 BaseClass
:
中实现
require_relative "base_class"
class Item < BaseClass
attr_accessor :price, :name
def initialize(attributes = {})
@price = attributes[:price]
@name = attributes[:name]
end
validates_presence_of :name
validates_numericality_of :price
end
我的问题是: 验证 validates_presence_of
,validates_numericality_of
将是 class 方法。如何访问实例对象以验证这些 class 方法中的名称和价格数据?
class BaseClass
attr_accessor :errors
def initialize
@errors = []
end
def valid?
@errors.empty?
end
class << self
def validates_presence_of(attribute)
begin
# HERE IS THE PROBLEM, self HERE IS THE CLASS NOT THE INSTANCE!
data = self.send(attribute)
if data.empty?
@errors << ["#{attribute} can't be blank"]
end
rescue
end
end
def validates_numericality_of(attribute)
begin
data = self.send(attribute)
if data.empty? || !data.integer?
@valid = false
@errors << ["#{attribute} must be number"]
end
rescue
end
end
end
end
查看 ActiveModel,您可以看到它在调用 validate_presence_of
时不进行实际验证。参考:presence.rb.
它实际上通过validates_with
为验证器列表创建了一个验证器实例(这是一个class变量_validators
);然后在记录的实例化过程中通过回调调用此验证器列表。参考:with.rb and validations.rb.
我做了上面的简化版,但它与我认为的ActiveModel相似。 (跳过回调等)
class PresenceValidator
attr_reader :attributes
def initialize(*attributes)
@attributes = attributes
end
def validate(record)
begin
@attributes.each do |attribute|
data = record.send(attribute)
if data.nil? || data.empty?
record.errors << ["#{attribute} can't be blank"]
end
end
rescue
end
end
end
class BaseClass
attr_accessor :errors
def initialize
@errors = []
end
end
编辑:就像 SimpleLime 指出的那样,验证器列表将被共享,如果它们在基础 class 中,它会导致所有项目共享属性(这显然会失败,如果属性集有任何不同)。
它们可以提取到一个单独的 module Validations
中并包含在内,但我将它们留在了这个答案中。
require_relative "base_class"
class Item < BaseClass
attr_accessor :price, :name
@@_validators = []
def initialize(attributes = {})
super()
@price = attributes[:price]
@name = attributes[:name]
end
def self.validates_presence_of(attribute)
@@_validators << PresenceValidator.new(attribute)
end
validates_presence_of :name
def valid?
@@_validators.each do |v|
v.validate(self)
end
@errors.empty?
end
end
p Item.new(name: 'asdf', price: 2).valid?
p Item.new(price: 2).valid?
参考文献:
首先,让我们尝试将验证融入模型中。我们会在它工作后提取它。
我们的起点是 Item
,没有任何类型的验证:
class Item
attr_accessor :name, :price
def initialize(name: nil, price: nil)
@name = name
@price = price
end
end
我们将添加一个方法 Item#validate
,该方法将 return 表示错误消息的字符串数组。如果模型有效,则数组将为空。
class Item
attr_accessor :name, :price
def initialize(name: nil, price: nil)
@name = name
@price = price
end
def validate
validators.flat_map do |validator|
validator.run(self)
end
end
private
def validators
[]
end
end
验证模型意味着迭代所有关联的验证器,运行 它们在模型上并收集结果。请注意,我们提供了 Item#validators
的虚拟实现,return 是一个空数组。
验证器是响应 #run
和 return 错误数组(如果有)的对象。让我们定义 NumberValidator
来验证给定属性是否是 Numeric
的实例。此 class 的每个实例负责验证 单个参数 。我们需要将属性名称传递给验证器的构造函数,以使其知道要验证哪个属性:
class NumberValidator
def initialize(attribute)
@attribute = attribute
end
def run(model)
unless model.public_send(@attribute).is_a?(Numeric)
["#{@attribute} should be an instance of Numeric"]
end
end
end
如果我们 return 来自 Item#validators
的验证器并将 price
设置为 "foo"
,它将按预期工作。
让我们将与验证相关的方法提取到一个模块中。
module Validation
def validate
validators.flat_map do |validator|
validator.run(self)
end
end
private
def validators
[NumberValidator.new(:price)]
end
end
class Item
include Validation
# ...
end
应在每个模型的基础上定义验证器。为了跟踪它们,我们将在模型 class 上定义一个 class 实例变量 @validators
。它只是由为给定模型指定的一组验证器组成。我们需要一些元编程来实现这一点。
当我们将任何模型包含到 class 中时,将在模型上调用 included
并接收 class 模型作为参数包含在其中。我们可以使用此方法在包含时自定义 class。我们将使用 #class_eval
这样做:
module Validation
def self.included(klass)
klass.class_eval do
# Define a class instance variable on the model class.
@validators = [NumberValidator.new(:price)]
def self.validators
@validators
end
end
end
def validate
validators.flat_map do |validator|
validator.run(self)
end
end
def validators
# The validators are defined on the class so we need to delegate.
self.class.validators
end
end
我们需要一种向模型添加验证器的方法。让我们 Validation
在模型 class:
上定义 add_validator
module Validation
def self.included(klass)
klass.class_eval do
@validators = []
# ...
def self.add_validator(validator)
@validators << validator
end
end
end
# ...
end
现在,我们可以执行以下操作:
class Item
include Validation
attr_accessor :name, :price
add_validator NumberValidator.new(:price)
def initialize(name: nil, price: nil)
@name = name
@price = price
end
end
这应该是一个很好的起点。您可以进行许多进一步的增强:
- 更多验证者。
- 可配置验证器。
- 条件验证器。
- 用于验证器的 DSL(例如
validate_presence_of
)。
- 自动验证器发现(例如,如果您定义
FooValidator
,您将能够自动调用 validate_foo
)。
如果您的目标是模仿 ActiveRecord,那么其他答案已涵盖。但是如果你真的想专注于一个简单的 PORO,那么你可能会重新考虑 class 方法:
class Item < BaseClass
attr_accessor :price, :name
def initialize(attributes = {})
@price = attributes[:price]
@name = attributes[:name]
end
# validators are defined in BaseClass and are expected to return
# an error message if the attribute is invalid
def valid?
errors = [
validates_presence_of(name),
validates_numericality_of(price)
]
errors.compact.none?
end
end
如果之后需要访问错误,则需要存储它们:
class Item < BaseClass
attr_reader :errors
# ...
def valid?
@errors = {
name: [validates_presence_of(name)].compact,
price: [validates_numericality_of(price)].compact
}
@errors.values.flatten.compact.any?
end
end
我不明白在 Ruby 中实施 PORO 验证的意义所在。我会在 Rails 而不是 Ruby.
中这样做
因此,假设您有一个 Rails 项目。为了模拟 PORO 的 Active Record 验证,您还需要具备 3 个条件:
PORO 中的某种 save
实例方法(从中调用验证)。
一个 Rails 控制器在你的 PORO 上处理 CRUD。
一个 Rails 带有脚手架快闪消息区域的视图。
提供所有这 3 个条件,我以这种方式实现了 PORO 验证(只是为了 name
简单):
require_relative "base_class"
class Item < BaseClass
attr_accessor :price, :name
include ActiveModel::Validations
class MyValidator
def initialize(attrs, record)
@attrs = attrs
@record = record
end
def validate!
if @attrs['name'].blank?
@record.errors[:name] << 'can\'t be blank.'
end
raise ActiveRecord::RecordInvalid.new(@record) unless @record.errors[:name].blank?
end
end
def initialize(attributes = {})
@price = attributes[:price]
@name = attributes[:name]
end
# your PORO save method
def update_attributes(attrs)
MyValidator.new(attrs, self).validate!
#...actual update code here
save
end
end
在您的控制器中,您必须手动处理异常(因为您的 PORO 在 ActiveRecord 之外):
class PorosController < ApplicationController
rescue_from ActiveRecord::RecordInvalid do |exception|
redirect_to :back, alert: exception.message
end
...
end
并且在一个视图中 - 只是一个通用的脚手架生成的代码。像这样(或类似的):
<%= form_with(model: poro, local: true) do |form| %>
<% if poro.errors.any? %>
<div id="error_explanation">
<h2><%= pluralize(poro.errors.count, "error") %> prohibited this poro from being saved:</h2>
<ul>
<% poro.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="field">
<%= form.label :name %>
<%= form.text_field :name, id: :poro_name %>
</div>
<div class="actions">
<%= form.submit %>
</div>
<% end %>
就是这样。保持一切简单。
我正在尝试在 Ruby 中实施自己的验证以供练习。
这里有一个 class Item
有 2 个验证,我需要在 BaseClass
:
require_relative "base_class"
class Item < BaseClass
attr_accessor :price, :name
def initialize(attributes = {})
@price = attributes[:price]
@name = attributes[:name]
end
validates_presence_of :name
validates_numericality_of :price
end
我的问题是: 验证 validates_presence_of
,validates_numericality_of
将是 class 方法。如何访问实例对象以验证这些 class 方法中的名称和价格数据?
class BaseClass
attr_accessor :errors
def initialize
@errors = []
end
def valid?
@errors.empty?
end
class << self
def validates_presence_of(attribute)
begin
# HERE IS THE PROBLEM, self HERE IS THE CLASS NOT THE INSTANCE!
data = self.send(attribute)
if data.empty?
@errors << ["#{attribute} can't be blank"]
end
rescue
end
end
def validates_numericality_of(attribute)
begin
data = self.send(attribute)
if data.empty? || !data.integer?
@valid = false
@errors << ["#{attribute} must be number"]
end
rescue
end
end
end
end
查看 ActiveModel,您可以看到它在调用 validate_presence_of
时不进行实际验证。参考:presence.rb.
它实际上通过validates_with
为验证器列表创建了一个验证器实例(这是一个class变量_validators
);然后在记录的实例化过程中通过回调调用此验证器列表。参考:with.rb and validations.rb.
我做了上面的简化版,但它与我认为的ActiveModel相似。 (跳过回调等)
class PresenceValidator
attr_reader :attributes
def initialize(*attributes)
@attributes = attributes
end
def validate(record)
begin
@attributes.each do |attribute|
data = record.send(attribute)
if data.nil? || data.empty?
record.errors << ["#{attribute} can't be blank"]
end
end
rescue
end
end
end
class BaseClass
attr_accessor :errors
def initialize
@errors = []
end
end
编辑:就像 SimpleLime 指出的那样,验证器列表将被共享,如果它们在基础 class 中,它会导致所有项目共享属性(这显然会失败,如果属性集有任何不同)。
它们可以提取到一个单独的 module Validations
中并包含在内,但我将它们留在了这个答案中。
require_relative "base_class"
class Item < BaseClass
attr_accessor :price, :name
@@_validators = []
def initialize(attributes = {})
super()
@price = attributes[:price]
@name = attributes[:name]
end
def self.validates_presence_of(attribute)
@@_validators << PresenceValidator.new(attribute)
end
validates_presence_of :name
def valid?
@@_validators.each do |v|
v.validate(self)
end
@errors.empty?
end
end
p Item.new(name: 'asdf', price: 2).valid?
p Item.new(price: 2).valid?
参考文献:
首先,让我们尝试将验证融入模型中。我们会在它工作后提取它。
我们的起点是 Item
,没有任何类型的验证:
class Item
attr_accessor :name, :price
def initialize(name: nil, price: nil)
@name = name
@price = price
end
end
我们将添加一个方法 Item#validate
,该方法将 return 表示错误消息的字符串数组。如果模型有效,则数组将为空。
class Item
attr_accessor :name, :price
def initialize(name: nil, price: nil)
@name = name
@price = price
end
def validate
validators.flat_map do |validator|
validator.run(self)
end
end
private
def validators
[]
end
end
验证模型意味着迭代所有关联的验证器,运行 它们在模型上并收集结果。请注意,我们提供了 Item#validators
的虚拟实现,return 是一个空数组。
验证器是响应 #run
和 return 错误数组(如果有)的对象。让我们定义 NumberValidator
来验证给定属性是否是 Numeric
的实例。此 class 的每个实例负责验证 单个参数 。我们需要将属性名称传递给验证器的构造函数,以使其知道要验证哪个属性:
class NumberValidator
def initialize(attribute)
@attribute = attribute
end
def run(model)
unless model.public_send(@attribute).is_a?(Numeric)
["#{@attribute} should be an instance of Numeric"]
end
end
end
如果我们 return 来自 Item#validators
的验证器并将 price
设置为 "foo"
,它将按预期工作。
让我们将与验证相关的方法提取到一个模块中。
module Validation
def validate
validators.flat_map do |validator|
validator.run(self)
end
end
private
def validators
[NumberValidator.new(:price)]
end
end
class Item
include Validation
# ...
end
应在每个模型的基础上定义验证器。为了跟踪它们,我们将在模型 class 上定义一个 class 实例变量 @validators
。它只是由为给定模型指定的一组验证器组成。我们需要一些元编程来实现这一点。
当我们将任何模型包含到 class 中时,将在模型上调用 included
并接收 class 模型作为参数包含在其中。我们可以使用此方法在包含时自定义 class。我们将使用 #class_eval
这样做:
module Validation
def self.included(klass)
klass.class_eval do
# Define a class instance variable on the model class.
@validators = [NumberValidator.new(:price)]
def self.validators
@validators
end
end
end
def validate
validators.flat_map do |validator|
validator.run(self)
end
end
def validators
# The validators are defined on the class so we need to delegate.
self.class.validators
end
end
我们需要一种向模型添加验证器的方法。让我们 Validation
在模型 class:
add_validator
module Validation
def self.included(klass)
klass.class_eval do
@validators = []
# ...
def self.add_validator(validator)
@validators << validator
end
end
end
# ...
end
现在,我们可以执行以下操作:
class Item
include Validation
attr_accessor :name, :price
add_validator NumberValidator.new(:price)
def initialize(name: nil, price: nil)
@name = name
@price = price
end
end
这应该是一个很好的起点。您可以进行许多进一步的增强:
- 更多验证者。
- 可配置验证器。
- 条件验证器。
- 用于验证器的 DSL(例如
validate_presence_of
)。 - 自动验证器发现(例如,如果您定义
FooValidator
,您将能够自动调用validate_foo
)。
如果您的目标是模仿 ActiveRecord,那么其他答案已涵盖。但是如果你真的想专注于一个简单的 PORO,那么你可能会重新考虑 class 方法:
class Item < BaseClass
attr_accessor :price, :name
def initialize(attributes = {})
@price = attributes[:price]
@name = attributes[:name]
end
# validators are defined in BaseClass and are expected to return
# an error message if the attribute is invalid
def valid?
errors = [
validates_presence_of(name),
validates_numericality_of(price)
]
errors.compact.none?
end
end
如果之后需要访问错误,则需要存储它们:
class Item < BaseClass
attr_reader :errors
# ...
def valid?
@errors = {
name: [validates_presence_of(name)].compact,
price: [validates_numericality_of(price)].compact
}
@errors.values.flatten.compact.any?
end
end
我不明白在 Ruby 中实施 PORO 验证的意义所在。我会在 Rails 而不是 Ruby.
中这样做因此,假设您有一个 Rails 项目。为了模拟 PORO 的 Active Record 验证,您还需要具备 3 个条件:
PORO 中的某种
save
实例方法(从中调用验证)。一个 Rails 控制器在你的 PORO 上处理 CRUD。
一个 Rails 带有脚手架快闪消息区域的视图。
提供所有这 3 个条件,我以这种方式实现了 PORO 验证(只是为了 name
简单):
require_relative "base_class"
class Item < BaseClass
attr_accessor :price, :name
include ActiveModel::Validations
class MyValidator
def initialize(attrs, record)
@attrs = attrs
@record = record
end
def validate!
if @attrs['name'].blank?
@record.errors[:name] << 'can\'t be blank.'
end
raise ActiveRecord::RecordInvalid.new(@record) unless @record.errors[:name].blank?
end
end
def initialize(attributes = {})
@price = attributes[:price]
@name = attributes[:name]
end
# your PORO save method
def update_attributes(attrs)
MyValidator.new(attrs, self).validate!
#...actual update code here
save
end
end
在您的控制器中,您必须手动处理异常(因为您的 PORO 在 ActiveRecord 之外):
class PorosController < ApplicationController
rescue_from ActiveRecord::RecordInvalid do |exception|
redirect_to :back, alert: exception.message
end
...
end
并且在一个视图中 - 只是一个通用的脚手架生成的代码。像这样(或类似的):
<%= form_with(model: poro, local: true) do |form| %>
<% if poro.errors.any? %>
<div id="error_explanation">
<h2><%= pluralize(poro.errors.count, "error") %> prohibited this poro from being saved:</h2>
<ul>
<% poro.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="field">
<%= form.label :name %>
<%= form.text_field :name, id: :poro_name %>
</div>
<div class="actions">
<%= form.submit %>
</div>
<% end %>
就是这样。保持一切简单。