在 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_ofvalidates_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 个条件:

  1. PORO 中的某种 save 实例方法(从中调用验证)。

  2. 一个 Rails 控制器在你的 PORO 上处理 CRUD。

  3. 一个 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 %>

就是这样。保持一切简单。