Rails es Misterioso y Funciona

En este artículo comparto un descubrimiento que hice en el código fuente de Rails cuando intentaba entender el método valid? de ActiveModel::Validations.

Me encontraba ideando una forma de resolver una tarea y lograr que las pruebas automáticas pasaran. Lo que quería hacer es que si en determinado caso una instancia de un modelo no existía(era nil), creaba una instancia nueva y pedía los errores: Payout.new.errors.

Cuando pruebo en consola, no funciona:

[1] pry(main)> Payout.new.errors
=&gt; #<ActiveModel::Errors:0x00007fc337507178
 @base=#<Payout:0x00007fc3375378c8 id: nil, voided_check: nil, bank_name: nil, routing_number: nil, account_number: nil, joining_for_supplemental_income: nil, therapist_id: nil, created_at: nil, updated_at: nil&gt;,
 @details={},
 @messages={}&gt;

La variable @messages está vacía. Al parecer no hay errores. Sin embargo, si hago algo como lo siguiente:

[5] pry(main)&gt; p = Payout.new
=&gt; #<Payout:0x00007fc33129e900 id: nil, voided_check: nil, bank_name: nil, routing_number: nil, account_number: nil, joining_for_supplemental_income: nil, therapist_id: nil, created_at: nil, updated_at: nil&gt;
[6] pry(main)&gt; p.valid?
=&gt; false
[7] pry(main)&gt; p.errors
=&gt; #<ActiveModel::Errors:0x00007fc331ea1c70
 @base=#<Payout:0x00007fc33129e900 id: nil, voided_check: nil, bank_name: nil, routing_number: nil, account_number: nil, joining_for_supplemental_income: nil, therapist_id: nil, created_at: nil, updated_at: nil&gt;,
 @details={:therapist=&gt;[{:error=&gt;:blank}], :voided_check=&gt;[{:error=&gt;:blank}], :bank_name=&gt;[{:error=&gt;:blank}], :routing_number=&gt;[{:error=&gt;:blank}], :account_number=&gt;[{:error=&gt;:blank}], :joining_for_supplemental_income=&gt;[{:error=&gt;:blank}]},
 @messages={:therapist=&gt;["must exist"], :voided_check=&gt;["can't be blank"], :bank_name=&gt;["can't be blank"], :routing_number=&gt;["can't be blank"], :account_number=&gt;["can't be blank"], :joining_for_supplemental_income=&gt;["can't be blank"]}&gt;

Vemos que al enviar el mensaje valid?a la instancia de Payout, luego de que preguntemos por los errors ya vemos datos en @messages.

valid?con responsabilidad no tan separada?

Curiosamente, ese mismo día(antes de trabajar en el código) había leído un artículo de Dan Manges titulado ActiveModel::Validations and Command/Query Separation.

En el texto el autor describe como este método en cuestión no sigue el principio de Separación de Comando y Consulta ya que por un lado ejecuta validaciones pero también modifica el arreglo de errors del objeto.

Ya que el método no se rige por dicho principio, la forma en la que quise programar la solución no funcionaba. Me faltaba usar el método valid?.

Indagando en el fondo de Rails

Dicen que la curiosidad es la madre de todas las invenciones, ¿no? Sea lo que sea, me intrigó saber cómo estaba escrito el método valid? de ActiveModel::Validations así que cloné el repositorio de Rails y lo abrí con mi editor de código.

Primero, busqué el fuente de valid?

# activemodel/lib/activemodel/validations.rb, línea 336
def valid?(context = nil)
  current_context, self.validation_context = validation_context, context
  errors.clear
  run_validations!
ensure
  self.validation_context = current_context
end

De todo lo que hay, me llama la atención la ejecución de un método privado run_validations!.

Cuando busco dicho método, encuentro esta definición

# activemodel/lib/activemodel/validations.rb, línea 408
def run_validations!
  _run_validate_callbacks
  errors.empty?
end

En varios lenguajes de programación existe una convención implícita de que métodos que empiecen con un guion bajo son privados(muy a pesar del nivel de visibilidad que tengan).

Teniendo eso en cuenta, supongo que _run_validate_callbacks es un método privado que debe estar definido en alguna parte pero ocurre algo extraño cuando decido buscarlo.

El buscador de Sublime Text me devolvió una coincidencia la cual correspondía a la misma línea dentro del método run_validations!.

¿Cómo así?

Luego de varias vueltas presiento que se trata de metaprogramación por lo cual empiezo a hacer búsquedas con el patrón guion bajo más el texto *run_*

Pasados varios intentos, encuentro lo que buscaba:

# activesupport/lib/activesupport/callbacks.rb, línea 806
def define_callbacks(*names)
  options = names.extract_options!

  names.each do |name|
    name = name.to_sym

    set_callbacks name, CallbackChain.new(name, options)

    module_eval <<-RUBY, __FILE__, __LINE__ + 1
      def _run_#{name}_callbacks(&amp;block)
        run_callbacks #{name.inspect}, &amp;block
      end

      def self._#{name}_callbacks
        get_callbacks(#{name.inspect})
      end

      def self._#{name}_callbacks=(value)
        set_callbacks(#{name.inspect}, value)
      end

      def _#{name}_callbacks
        __callbacks[#{name.inspect}]
      end
    RUBY
  end
end

Acá notamos un poco de metaprogramación con el uso de module_eval. Como el código de _run_validate_callbacks no está escrito en ningún archivo sino que se genera “al vuelo” cuando arranca Rails(rails server), no se encontraba con una búsqueda de texto en el editor.

No era mi intención entender que pasaba aquí aunque noté que no terminaba el recorrido en este método. Si nos fijamos en la línea 12, vemos que debemos saltar a otro método llamado run_callbacks.

Veamos qué hace:

# activesupport/lib/activesupport/callbacks, línea 94
def run_callbacks(kind)
  callbacks = __callbacks[kind.to_sym]

  if callbacks.empty?
    yield if block_given?
  else
    env = Filters::Environment.new(self, false, nil)
    next_sequence = callbacks.compile

    invoke_sequence = Proc.new do
      skipped = nil
      while true
        current = next_sequence
        current.invoke_before(env)
        if current.final?
          env.value = !env.halted &amp;&amp; (!block_given? || yield)
        elsif current.skip?(env)
          (skipped ||= []) << current
          next_sequence = next_sequence.nested
          next
        else
          next_sequence = next_sequence.nested
          begin
            target, block, method, *arguments = current.expand_call_template(env, invoke_sequence)
            target.send(method, *arguments, &amp;block)
          ensure
            next_sequence = current
          end
        end
        current.invoke_after(env)
        skipped.pop.invoke_after(env) while skipped &amp;&amp; skipped.first
        break env.value
      end
    end

    # Common case: no 'around' callbacks defined
    if next_sequence.final?
      next_sequence.invoke_before(env)
      env.value = !env.halted &amp;&amp; (!block_given? || yield)
      next_sequence.invoke_after(env)
      env.value
    else
      invoke_sequence.call
    end
  end
end

A esta implementación menos que le presté atención. Y hasta aquí sí que dejé de investigar que seguía.

En todo caso, si nos detenemos un poco más notamos los siguientes bloques de código:

# activesupport/lib/activesupport/callbacks, línea 94
def run_callbacks(kind)
  callbacks = __callbacks[kind.to_sym]

  if callbacks.empty?
    yield if block_given?
  else
    env = Filters::Environment.new(self, false, nil)
    next_sequence = callbacks.compile

    invoke_sequence = Proc.new do
      skipped = nil
      while true
      end
    end

    # Common case: no 'around' callbacks defined
    if next_sequence.final?
      next_sequence.invoke_before(env)
      env.value = !env.halted && (!block_given? || yield)
      next_sequence.invoke_after(env)
      env.value
    else
      invoke_sequence.call
    end
  end
end
  • Caso positivo de un condicional callbacks.empy?
  • Caso negativo del condicional
  • Dentro del else, se almacena dentro de una variable un bloque con un ciclo while infinito
  • Dentro del else, otro condicional con rama else.

Dentro del ciclo while true pasan más cosas pero la verdad no me dieron más ganas de seguir tratando de entenderlo.

¿Qué aprendí?

Aprendí que en Rails hay cosas complejas que sin el contexto adecuado no podré entender.

Aprendí que Rails es sencillo por fuera pero que muchas de las cosas que usamos son bastante complejas en el fondo.

Y a pesar de esa complejidad, Rails funciona muy bien. En todos los años que llevo trabajando con el framework, muchos de los errores que he sufrido han sido más por no entender cómo usarlo que por otra cosa.

Sí tiene sus falencias pero también tiene mucho terreno cubierto.

No encontré la respuesta definitiva que esperaba, sin embargo, lo que encontré me deja satisfecho y también motivado a seguir leyendo más el código fuente de Ruby on Rails.

Autor: cesc1989

Ingeniero de Sistemas que le gusta escribir y compartir sobre recursos que considera útiles, además que le gusta leer manga y ver anime.

Responder

Introduce tus datos o haz clic en un icono para iniciar sesión:

Logo de WordPress.com

Estás comentando usando tu cuenta de WordPress.com. Cerrar sesión /  Cambiar )

Google photo

Estás comentando usando tu cuenta de Google. Cerrar sesión /  Cambiar )

Imagen de Twitter

Estás comentando usando tu cuenta de Twitter. Cerrar sesión /  Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Cerrar sesión /  Cambiar )

Conectando a %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.