Wazery
Wazery

Reputation: 15902

Implementing validations in PORO

I am trying to implement my own validations in Ruby for practice.

Here is a class Item that has 2 validations, which I need to implement in the 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

My problem is: the validations validates_presence_of, and validates_numericality_of will be class methods. How can I access the instance object to validate the name, and price data within these class methods?

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

Upvotes: 2

Views: 2610

Answers (4)

Ho Man
Ho Man

Reputation: 2345

Looking at ActiveModel, you can see that it doesn't do the actual validation when validate_presence_of is called. Reference: presence.rb.

It actually creates an instance of a Validator to a list of validators (which is a class variable _validators) via validates_with; this list of validators is then called during the record's instantiation via callbacks. Reference: with.rb and validations.rb.

I made a simplified version of the above, but it is similar to what ActiveModel does I believe. (Skipping callbacks and all that)

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

EDIT: Like what SimpleLime pointed out, the list of validators will be shared across and if they are in the base class, it would cause all the items to share the attributes (which would obviously fail if the set of attributes are any different).

They can be extracted out into a separate module Validations and included but I've left them in in this answer.

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?

References:

Upvotes: 3

prograils
prograils

Reputation: 2386

I don't understand the point to implement PORO validations in Ruby. I'd do that in Rails rather than in Ruby.

So let's assume you have a Rails project. In order to mimic the Active Record validations for your PORO, you need to have also 3 things:

  1. Some kind of a save instance method within your PORO (to call the validation from).

  2. A Rails controller handling CRUD on your PORO.

  3. A Rails view with a scaffold flash messages area.

Provided all 3 these conditions I implemented the PORO validation (just for name for simplicity) this way:

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

In your controller you have to manually process the exception (as your PORO is outside ActiveRecord):

class PorosController < ApplicationController
  rescue_from ActiveRecord::RecordInvalid do |exception|
    redirect_to :back, alert: exception.message
  end
...
end

And in a view - just a common scaffold-generated code. Something like this (or similar):

<%= 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 %>

That's it. Just keep it all simple.

Upvotes: -2

cainlevy
cainlevy

Reputation: 181

If your goal is to mimic ActiveRecord, the other answers have you covered. But if you really want to focus on a simple PORO, then you might reconsider the class methods:

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

If you need access to the errors afterwards, you'll need to store them:

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

Upvotes: 0

Greg Navis
Greg Navis

Reputation: 2934

First, let's try to have validation baked into the model. We'll extract it once it's working.

Our starting point is Item without any kind of validation:

class Item
  attr_accessor :name, :price

  def initialize(name: nil, price: nil)
    @name = name
    @price = price
  end
end

We'll add a single method Item#validate that'll return an array of strings representing errors messages. If a model is valid the array will be empty.

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

Validating a model means iterating over all associated validators, running them on the model and collecting results. Notice we provided a dummy implementation of Item#validators that returns an empty array.

A validator is an object that responds to #run and returns an array of errors (if any). Let's define NumberValidator that verifies whether a given attribute is an instance of Numeric. Each instance of this class is responsible for validating a single argument. We need to pass the attribute name to the validator's constructor to make it aware which attribute to validate:

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

If we return this validator from Item#validators and set price to "foo" it'll work as expected.

Let's extract validation-related methods to a module.

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

Validators should be defined on a per-model basis. In order to keep track of them, we'll define a class instance variable @validators on the model class. It'll simply by an array of validators specified for the given model. We need a bit of meta-programming to make this happen.

When we include any model into a class then included is called on the model and receives the class the model is included in as an argument. We can use this method to customize the class at inclusion time. We'll use #class_eval to do so:

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

We need a way to add validators to the model. Let's make Validation define add_validator on the model class:

module Validation
  def self.included(klass)
    klass.class_eval do
      @validators = []

      # ...

      def self.add_validator(validator)
        @validators << validator
      end
    end
  end

  # ...
end

Now, we can do the following:

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

This should be a good starting point. There're lots of further enhancements you can make:

  • More validators.
  • Configurable validators.
  • Conditional validators.
  • A DSL for validators (e.g. validate_presence_of).
  • Automatic validator discovery (e.g. if you define FooValidator you'll automatically be able to call validate_foo).

Upvotes: 0

Related Questions