Dagi3d v4

Cambiando la locale de una clase en Globalize2

En el último proyecto que estuve realizando con RoR necesitaba ofrecer soporte multiidioma para los modelos así que opté por utilizar Globalize2 ya que ya había estado trasteando con esta librería y me parecía muy cómoda de usar(aunque todavía le quedan algunas cosas por pulir).

El caso es que desde hace relativamente poco permite cambiar la locale de una clase en concreto sin necesidad de cambiar el idioma de toda la aplicación. Así se podría tener nuestra aplicación en un idioma determinado y trabajar con una instancia de un modelo en otro totalmente distinto.

El problema era que con la implementación actual, si se cambiaba la locale de una clase en concreto, se cambiaba automáticamente en todas las demás clases que tuvieran campos traducibles con Globalize2:­ 

­I18n.locale = 'es'
Post.locale = 'en'
Post.locale         # en
Category.locale     # en <- WTF?!

Esto sucede por la utilización de variables de clase y la implementación de éstas en Ruby­. Con Ruby, si se modifica el valor de una variable de clase que ha heredado de otra, cambiará en todas las demás clases que hereden de la misma:


class Polygon
  @@sides = 0

  def self.sides
    @@sides
  end
end

class Triangle < Polygon
  @@sides = 3
end

class Square < Polygon
­  @@sides = 4
end

Triangle.sides # 4 <- WTF?!­
Rails provee un mecanismo para solucionar esto a través de los métodos write_inheritable_attribute y read_inheritable_attribute, así que escribí un mini parche para este plugin de Rails. Aquí el commit del fork en Github

Probando Globalize2

Vi que se había liberado hace poco una nueva versión de Globalize, el plugin para Rails que permite tener traducciones de nuestros modelos, así que me animé a probarlo. Ahora es compatible con Rails 2.2 y hace uso de la nueva api para la internacionalización de nuestras aplicaciones. Además, ahora en lugar de tener una única tabla con todas las traducciones, hay que crear una tabla adicional para cada modelo con los campos que queramos que sean traducibles(tal como hace el plugin translate_columns de Samuel Lown). Por ahora hay que escribir la migración a mano, pero se planea hacer un generator para automatizar este paso(si saco un momentillo igual me animo a escribirlo yo, que nunca hice uno).
De momento parece que funciona bastante bien, salvo un detalle que me tuvo entretenido un buen rato y que comento por si alguien se encuentra en la misma situación.

En todos los ejemplos de la documentación aparece que podemos indicar la nueva locale usando un símbolo, pero resulta que si lo hacemos así, no se puede recuperar luego el campo traducido(aunque sí que se guarda correctamente en la base de datos) y lo que hay que hacer es utilizar cadenas en su lugar.

Supongamos que tenemos la clase Post con la siguiente migración:

# Post
class Post < ActiveRecord::Base
  translates :title
end­
­­
# CreatePosts
class CreatePosts < ActiveRecord::Migration
  def self.up
    create_table :posts do |t|
      t.timestamps
    end
    create_table :post_translations, :force => true do |t|
      t.references :post
      t.string :locale
      t.string :title
      t.timestamps
    end
  end

  def self.down
    drop_table :post_translations
    drop_table :posts
  end
end

Podemos ver que si usa­­mos un símbolo para nuestra locale, el campo nos devuelve un nil, pero si usamos una cadena, devuelve el valor correcto:

gambitero:rails-test dagi3d$ ./script/console 
Loading development environment (Rails 2.2.0)
>> I18n.locale = :es
=> :es
>> post = Post.new(:title => 'titulo')
=> #
>> post.title
=> nil
>> I18n.locale = "es"
=> "es"
>> post.title = "titulo"
­=> "titulo"
>> post.title
=> "titulo"
>> I18n.locale = "en"
=> "en"
>> post.title = "title"
=> "title"
>> I18n.locale = "es"
=> "es"
>> post.title
=> "titulo"

También­ comentar que si tenemos traducido un campo en la locale por defecto(inicialmente ésta es 'en-US') e intentamos acceder a un atributo de una que todavía no tiene ningún valor asignado, se devolverá el valor de la locale por defecto. Si queremos evitar este comportamiento basta con comentar la línea 26 del fichero vendor/plugins/globalize2/lib/globalize/locale/fallbacks.rb para que no añada ésta a la lista de fallbacks(otra opción sería redefinir el constructor de la clase Globalize::Locale::FallBacks)

Por último decir que de momento la asociación que se crea entre nuestra clase y la generada por el plugin con las traducciones no se carga con 'eager loading' pero es una cosa que tienen prevista hacer.

Validando las asociaciones de ActiveRecord con RSpec

Después de estar un tiempo sin tocar Rails para nada, me puse el otro día a cacharrerar un poco con RSpec y se me ocurrió escribir un matcher para validar las asociaciones de los modelos, ya que consideraba que un simple @objeto.should respond_to(:metodo) realmente tampoco garantiza nada.
Lo único 'interesante' que puede aportar este código es que a la hora de escribir nuestros specs, basta con poner directamente el nombre de la relación, ya que la clase asociada se obtiene de manera automática(bendita sea la convención sobre la configuración face-smile.png), al contrario que el resto de ejemplos que pude encontrar por ahí, donde se indica la clase y si se desea, se indica aparte el nombre de la relación(y creo que así se aporta algo de legibilidad a los specs) :

@record.should have_many(:songs) # utiliza la clase Song

@record.should belong_to(:artist) # utiliza la clase Artist­

@record.should have_one(:cover) # utiliza la clase Cover

Si fuese necesario también se puede indicar de manera manual la clase del modelo relacionado:

@record.should have_many(:favorite_songs).from_class(Song)
module ARAssociationsMatchers
  
  # ARAssociationMatcher
  #
  class ARAssociationMatcher
  
    def initialize(expected, macro)
      @expected_association = expected
      @expected_macro = macro
    end
    
    def matches?(target)
      @target = target
      
      unless @expected_class.nil?
        expected_class = @expected_class
      else
        expected_class_name = @expected_association.to_s.singularize.camelize
        expected_class = Kernel.const_get(expected_class_name)
      end
      
      reflection = target.class.reflect_on_association(@expected_association)
      
      !reflection.nil? && (reflection.macro == @expected_macro) && (reflection.klass == expected_class)
    end
    
    def from_class(expected_class)
      @expected_class = expected_class
      self
    end
    
    def failure_message
      "expected #{@target.inspect} to #{@expected_macro} #{@expected_association.inspect}, but it didn't"
    end
    
    def negative_failure_message
      "expected #{@target.inspect} not to #{@expected_macro} #{@expected_association.inspect}, but it didn't"
    end
    
  end
  
  # matchers functions
  #
  def have_many(expected)
    ARAssociationMatcher.new(expected, :has_many)
  end

  def have_one(expected)
    ARAssociationMatcher.new(expected, :has_one)
  end
  
  def belong_to(expected)
    ARAssociationMatcher.new(expected, :belongs_to)
  end
  
end
La idea inicial está tomada de ­este enlace

Back to the world

Bueno, después de bastante tiempo sin actualizar voy a ver si vuelvo a retomar el tema del blog. Últimamente han cambiado muchas cosas, tanto en el aspecto laboral/escolar como en el personal(eso sí, todas para bien), así que espero volver a darle caña al tema de Rails que lo tenía bastante abandonado. Me acaban de llegar de Amazon los libros The Ruby Way, The Rails Way(que ya había catado en su versión en pdf y me pareció muy bueno) y Agile Software Development with Scrum, así que ahora toca procrastinar a tope face-smile.png

Añadiendo nuevos tipos en las migraciones de Rails

En el proyecto con el que ando liado en mis r­atos libres, necesitaba añadir a varios modelos atributos que almaceneran decimales. En principio era tan simple como crear en cada migración las columnas con su tipo de dato correspondiente:

t.column :price, :precision => 6, :scale => 2, :default => nil

El caso es ­que se  me hacía un tanto repetitivo estar añadiendo la misma línea en todas las migraciones donde me hacía falta(sé que no es para tanto, pero a veces la vagancia me puede) y además no estaba usando el estilo de los nuevos atajos que trae Rails 2.0, así que la solución era tan sencilla como reabrir la clase ActiveRecord::ConnectionAdapters::TableDefinition(bendito Ruby) y crear el método necesario:

class ActiveRecord::ConnectionAdapters::TableDefinition
  
  def currency(*columns)
    columns.each do |column|
      self.column column, :decimal, :precision => 6, :scale => 2, :default => nil
    end
  end
  
end­
­

Y ya podía usar en todas mis migraciones el método t.price teniendo que indicar únicamente el nombre de la columna(o columnas) que quería crear:

class CreateLineItems < ActiveRecord::Migration
  def self.up
    create_table :line_items do |t|
      t.references :order
      t.references :item, :polymorphic => true
      t.currency :price
      t.timestamps
    end
  end

  def self.down
    drop_table :line_items
  end
end