Benito Serna Tips and tools for Ruby on Rails developers

Como hacer modelos más simples, usando validadores especializados.

November 23, 2014

Las validaciones de tus modelos de ActiveRecord parecieran muy convenientes, porque son muy fáciles de usar, tiene un DSL muy entendible y rails te las pone ahí a la mano para que las uses. Pero esto no dura para siempre.

Cuando empiezas una aplicación estas validaciones parecen inofensivas y hasta parece ridículo hacer pruebas para ellas. Después pasa que uno o dos de los modelos de tu aplicación (muy probablemente uno de ellos sea User) empieza a crecer y deja de ser “tan simple”, la clase ya tiene más de 300 lineas de código y el record es modificado de muchas formas diferentes, en diferentes controladores.

Cuando estos problemas empiezan a llegar a tu aplicación, comenzaras a ver cosas como:

user.save(validate: false)
class User < ActiveRecord::Base
  validates_format_of :password, with: password_format, on: [:registration]
end

Esto pasa porque algunos modelos son usados en diferentes contextos. Por ejemplo en Rides, el modelo Trip se puede crear, plublicar, editar (pero no siempre), se pueden apartar lugares, entre otras muchas otras posibilidades. Probablemente en tu aplicación pasa lo mismo con algunos modelos.

En estos momentos ya no parece tan ridículo hacer pruebas (¿o sí?). El problema es que ahora es hacer pruebas no es tan fácil, hay mucho acoplamiento, las pruebas se vuelven lentas y algunas veces ya no estas seguro de las reglas que en un momento quisieron ser implementadas.

Este problema es común, como ejemplo podemos ver el modelo Topic en Discourse

class Topic < ActiveRecord::Base
  validates :title, :presence => true,
                    :topic_title_length => true,
                    :quality_title => { :unless => :private_message? },
                    :unique_among  => { :unless => Proc.new { |t| (SiteSetting.allow_duplicate_topic_titles? || t.private_message?) },
                                        :message => :has_already_been_used,
                                        :allow_blank => true,
                                        :case_sensitive => false,
                                        :collection => Proc.new{ Topic.listable_topics } }

  validates :category_id,
            :presence => true,
            :exclusion => {
              :in => Proc.new{[SiteSetting.uncategorized_category_id]}
            },
            :if => Proc.new { |t|
                   (t.new_record? || t.category_id_changed?) &&
                   !SiteSetting.allow_uncategorized_topics &&
                   (t.archetype.nil? || t.archetype == Archetype.default) &&
                   (!t.user_id || !t.user.staff?)
            }
end

El problema es cuando crees que esto esta bien y que es normal no tener idea que esta pasando.

Definición del funcionamiento

La base para que funcionen este tipo de validaciones en una aplicación de rails sigue siendo ActiveModel ya que aprovecharemos el método ActiveModel::Errors#add.

Bueno ya hablamos mucho de el problema, continuemos con la solución. Imagina que estas trabajando en una aplicación para promover eventos, específicamente en la parte de registro de eventos. Y tienes que validar que el evento tenga un nombre, que tenga una fecha, y que la fecha no haya pasado.

Probablemente en la forma tienes un campo para el nombre del evento y otro para la fecha. Si usaras simple_form se vería como:

<%= simple_form_for @event do |f| %>
  <%= f.input :name %>
  <%= f.input :starts_at %>
<% end %>

Y si nos apegáramos lo más posible a un controlador de scaffold, es muy probable que tengas algo como:

class EventsController < ApplicationController
  def new
    @event = Event.new
  end

  def create
    @event = Event.new(params[:event])

    if @event.save
      redirect_to event_path(@event)
    else
      render :new
    end
  end
end

Bueno en lugar de delegar la responsabilidad al modelo, usaremos un objeto que se encargará de saber cuales son los errores y luego se los pasará al modelo para que los pueda mostrar en la vista.

class EventsController < ApplicationController
  def new
    @event = Event.new
  end

  def create
    @event = Event.new(params[:event])
    errors = EventValidator.errors_for(@event)

    if errors.empty?
      @event.save
      redirect_to event_path(@event)
    else
      @event.add_errors(errors)
      render :new
    end
  end
end

Para lograr esto necesitamos implementar el método Event#add_errors que sería algo así:

class Event < ActiveRecord::Base
  def add_errors(errors)
    errors.each { |error| self.errors.add(*error) }
  end
end

Este método Event#add_errors espera que los errores que recibe como parámetros tengan la siguiente forma:

[[:name, :blank], [:starts_at, :must_be_in_the_future]]

Es decir un arreglo de arreglos, donde cada elemento son los parámetros que describen un error en un objeto de ActiveRecord. Si analizamos el método más a detalle, realmente estamos haciendo:

event.errors.add(:name, :blank)
event.errors.add(:starts_at, :must_be_in_the_future)

Con esto podemos aprovechar que rails ya sabe traducir errores de este tipo a mensajes “mas humanos” usando I18n.

Implementar el validador

Para implementar la estructura de datos que necesita regresar nuestro validador empezaremos por definir el comportamiento con unas pruebas:

describe 'Event validator' do
  describe 'validates the presence of name' do
    example do
      event = event_with(name: 'Benito')
      expect(errors_for(event, :name)).to be_empty
    end

     example do
      event = event_with(name: '')
      expect(errors_for(event, :name)).to eq [[:name, :blank]]
    end
  end

  describe 'validates it starts in the future' do
    example do
      event = event_with(starts_at: '')
      expect(errors_for(event, :starts_at)).to eq [[:starts_at, :blank]]
    end

    example do
      event = event_with(starts_at: 2.days.from_now)
      expect(errors_for(event, :starts_at)).to be_empty
    end

    example do
      event = event_with(starts_at: 1.second.ago)
      expect(errors_for(event, :starts_at))
        .to eq [[:starts_at, :must_be_in_the_future]]
    end
  end

  def event_with(attributes)
    TestEventRecord.new(attributes)
  end

  def errors_for(event, attribute)
    errors = EventValidator.errors_for(event)
    errors.select { |error_attribute, _| error_attribute == attribute }
  end

  class TestEventRecord
    attr_reader :name, :starts

    def inititalize(name: nil, starts_at: nil)
      @name = name
      @starts_at = starts_at
    end
  end
end

Nota como en lugar de usar directamente un objeto Event que es un active record object, use un pequeño “mock” que en este caso tiene el mismo comportamiento. Aunque esto no es necesario, puede hacer que tus pruebas se tarden milisegundos, en lugar de segundos.

Si ves lo más importante es definir el comportamiento deseado, y que este sea claro, ya que la implementación puede hacerse de muchas formas. Una posible solución es la siguiente.

class EventValidator
  def inititalize(event)
    @event = event
  end

  def self.errors_for(event)
    new(event).errors
  end

  def errors
    [name_is_blank, is_not_in_the_future].compact
  end

  private

  attr_reader :event

  def name_is_blank
    [:name, :blank] if event.name.blank?
  end

  def is_not_in_the_future
    return [:starts_at, :blank] if event.starts_at.blank?

    if event.starts_at < Time.current
      [:starts_at, :must_be_in_the_future]
    end
  end
end

Esta es una implementación que a mi gusto es descriptiva pero tal vez tu lo puedes hacer mejor =).

Conclusión

Comparando este ejemplo con una posible implementación usando las validaciones de ActiveRecord, probablemente el ejemplo de las validaciones sea más fácil, pero definitivamente más complejo.

Una ventaja de utilizar este tipo de validaciones, es que son fácilmente desechables e intercambiables. Aparte que puedes hacer un validador para cada contexto que necesites.

Otra ventaja es que disminuyes la responsabilidad que hay en tus modelos y la distribuyes en objetos que tienen una sola responsabilidad.

¡Aparte las pruebas corren mucho más rápido!

Related articles

Weekly tips and tools for Ruby on Rails developers

I send an email each week, trying to share knowledge and fixes to common problems and struggles for ruby on rails developers, like How to fetch the latest-N-of-each record or How to test that an specific mail was sent or a Capybara cheatsheet. You can see more examples on Most recent posts or All post by topic.