Definindo attributos booleanos virtuais em rails
Estou desenvolvendo uma app em rails e me deparei com a seguinte situação: Precisava criar vários attributos booleanos virtuais em um model e precisava que eles se comportassem da mesma forma que uma coluna booleana do active record.
Quando temos um attributo do active record que representa uma coluna do tipo booleano, o active record se encarrega de converter os valores de e para booleano, é o que chamamos de 'typecast'. Dessa forma você pode atribuir um valor inteiro para esse attributo e o AR se encarrega de converter para verdadeiro ou falso.
Isso é necessário porque em ruby, todos os objetos retornam verdadeiro ao serem avaliados em uma expressão lógica, exceto objetos false e nil. Para comprovar isso podemos utilizar a técnica da dupla negação:
!!0 # => true !!1 # => true !!false # => false !!nil # => false !!'false' # => true !!'0' # => true
Portanto, se você quiser utilizar um atributo virtual em um form e espera que ele retorne verdadeiro ou falso vai obter um comportamento que pode não ser o esperado. Isso acontece porque ao enviar o formulário, todos os parâmetros são convertidos em string. Para ilustrar o que eu estou falando, imagine o seguinte cenário:
#app/models/engine.rb class Engine < ActiveRecord::Base attr_accessor :on end
# app/controllers/engines_controller.rb class EngineController < ApplicationController def new @engine = Engine.new end end
# app/views/engines/new.html.erb ... f.radio_button :on, true f.radio_button :on, false ...
No cenário acima, mesmo se o usuário selecionar a segunda opção, o attributo 'on' do objeto @engine vai ser verdadeiro. Isso acontece porque os parâmetros do formulário são serializados antes de serem enviados para o servidor, e o controller não faz nenhum tipo de conversão de dados. Typecast é responsabilidade do model.
Não seria interessante se pudessemos definir esse comportamento na classe engine com uma macro? Pois é, foi o que eu pensei. Imagine poder declarar accessors booleanos da mesma forma como declaramos accessors comuns em ruby e obter a conversão dos valores para booleano automaticamente. Ex:
#app/models/engine.rb class Engine < ActiveRecord::Base boolean_accessor :on end
Pensando nisso resolvi dar uma olhada no código fonte do rails pra saber como as colunas se comportam e descobri uma maneira interessante para definir os attributos reutilizando o comportamento das 'colunas' do rails.
Uma coluna booleana espera receber valores específicos e converte-os para verdadeiro ou falso comparando-os com um conjunto de valores pré-definidos. Esses valores estão armazenados em duas constantes na classe ActiveRecord::ConnectionAdapters::Column:
TRUE_VALUES = [true, 1, '1', 't', 'T', 'true', 'TRUE'].to_set FALSE_VALUES = [false, 0, '0', 'f', 'F', 'false', 'FALSE'].to_set
A classe Column também implementa o seguinte método:
# convert something to a boolean def value_to_boolean(value) if value.is_a?(String) && value.blank? nil else TRUE_VALUES.include?(value) end end
Dessa forma podemos atribuir um '0' para um atributo booleano e esse valor é automaticamente convertido para um valor booleano, nesse caso falso. O mesmo acontece se atribuir-mos '1', 'true', etc...
Com esse método e um pouco de meta-programação podemos criar uma macro que define attributos virtuais booleanos. Vamos criar a macro em um módulo chamado BooleanAccessors e vamos coloca-lo na pasta config/initializers.
#config/initializers/boolean_attributes.rb unless Module.const_defined?(:ActiveRecord) require 'active_record' end module BooleanAccessors def boolean_accessor(*attributes) class_eval do attributes.each do |attribute| define_method "#{attribute}=" do |value| instance_variable_set("@#{attribute}", ActiveRecord::ConnectionAdapters::Column.value_to_boolean(value)) end define_method attribute do return false if instance_variable_get("@#{attribute}").nil? instance_variable_get("@#{attribute}") end alias :"#{attribute}?" :"#{attribute}" end end end end
O código acima define um método chamado 'boolean_accessor' que recebe como argumento a lista de atributos booleanos que queremos definir. Ao ser executado, esse método define os attr_readers (getters), que retorna false se o attributo for nulo, do contrário, o valor do attributo é retornado. Perceba que o método utiliza 'instance_variable_get' para obter o valor do attributo.
Em seguida os 'attr_writers' (ou setter methods se você preferir) são definidos. Esses setters invocam o método 'value_to_boolean' da classe Column do active record, passando o valor do argumento que receberam e armazenam o retorno desse método na variavel de instância que corresponde ao attributo, para isso utilizamos o método 'instance_variable_set'.
Após definirmos os setters definimos os métodos predicados, uma vez que se trata de attributos booleanos faz sentido utilizarmos métodos predicados. Esses métodos predicados nada mais são que aliases para os getters com o mesmo nome acrescido de um ponto de interrogação.
Prontinho, já definimos a macro, agora podemos utiliza-la em um model. Para que essa macro fique disponível em uma classe, essa classe precisa 'extender' o módulo BooleanAccessors. como no exemplo:
#app/models/engine.rb class Engine < ActiveRecord::Base extend BooleanAccessors boolean_accessor :on, :working end engine = Engine.new(:on => '1', :working => 'true') engine.working # => true engine.working? # => true engine.on => # => true engine.on? => # => true engine.on = 0 engine.on # => false engine.on? # => false end
Observe como o objeto engine é inicializado; Os argumentos são todos strings, mesmo assim o objeto retorna verdadeiro ou faso para os attributos booleanos.
Você pode 'extender' a classe ActiveRecord::Base caso queira que essa macro fique disponível em todos os seus models:
#config/initializers/monkeys/active_record_base.rb module ActiveRecord class Base extend BooleanAccessors end end
É isso aí pessoal, esse é o meu primeiro post técnico aqui no blog, espero que seja útil. Se você tiver sugestões de melhorias para o código acima, ou encontrou uma maneira melhor de obter o efeito desejado, não deixe de postar um comentário. Eu não procurei por plugins que implementam esse comportamento, acredito que alguém ja deve ter implementado isso. Mesmo assim acho que o post é válido para ilustrar conceitos de meta-programação e alguns aspectos do active record.







