Reputation: 15902
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
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
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:
Some kind of a save
instance method within your PORO (to call the validation from).
A Rails controller handling CRUD on your PORO.
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
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
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:
validate_presence_of
).FooValidator
you'll automatically be able to call validate_foo
).Upvotes: 0