Alternative implementation of Globalize Model Translations

I’ve come up with an alternative way of implementing Globalize Model translations (i.e. localization of your ActiveRecord model attribute data) I think is pretty sweet.

This method is only meant to override the use of the Globalize “translates” class method in ActiveRecord::Base but still rely on globalize view translations.

[Update] – I’ve made a plugin out of this. Just download this tarball and extract to your vendor/plugins directory.

Usage

Assuming a schema like this:


ActiveRecord::Schema.define(:version => 1) do
  #Globalize tables excluded for clarity
  create_table "sections", :force => true do |t|
    t.column "name",                :string
  end
end

Step 1 – Add localized fields via a migration


class AddLocalizedFields < ActiveRecord::Migration
  def self.up
    #Assuming Catalan (ca_ES) is the base locale
    add_column :sections, :name_en, :string
    add_column :sections, :name_es, :string
    add_column :sections, :name_fr, :string
  end

  def self.down
    remove_column :sections, :name_en
    remove_column :sections, :name_es
    remove_column :sections, :name_fr
  end
end

Step 2 – Add meta magic to ActiveRecord::Base

Add the following to somewhere you can get it loaded (e.g. environment.rb, some file in lib which is required in environment.rb, a plugin, wherever)


class ActiveRecord::Base

  class << self

    #Localize model attributes
    def localizes(*attribute_names)
      attribute_names.each do |attribute_name|
        class_eval %{
          #Accessor that proxies to the right accessor for the current locale
          def #{attribute_name}
            unless Locale.base?
              localized_method = "#{attribute_name}_\#{Locale.active.language.code}"
              value = send(localized_method.to_sym) if respond_to?(localized_method.to_sym)
              return value ? value : read_attribute(:#{attribute_name})
            end
            read_attribute(:#{attribute_name})
          end

          #Accessor before typecasting that proxies to the right accessor for the current locale
          def #{attribute_name}_before_type_cast
            unless Locale.base?
              localized_method = "#{attribute_name}_\#{Locale.active.language.code}_before_type_cast"
              value = send(localized_method.to_sym) if respond_to?(localized_method.to_sym)
              return value ? value : read_attribute_before_type_cast('#{attribute_name}')
            end
            read_attribute_before_type_cast('#{attribute_name}')
          end

          #Write to appropriate localized attribute
          def #{attribute_name}=(value)
            unless Locale.base?
              localized_method = "#{attribute_name}_\#{Locale.active.language.code}"
              write_attribute(localized_method.to_sym, value) if respond_to?(localized_method.to_sym)
            else
              write_attribute(:#{attribute_name}, value)
            end
          end

          #Read base language attribute directly
          def _#{attribute_name}
            read_attribute(:#{attribute_name})
          end

          #Read base language attribute directly without typecasting
          def _#{attribute_name}_before_type_cast
            read_attribute_before_type_cast('#{attribute_name}')
          end

          #Write base language attribute directly
          def _#{attribute_name}=(value)
            write_attribute(:#{attribute_name}, value)
          end
        }
      end

      #Returns the localized_name of the supplied attribute for the
      #current locale
      #Useful when you have to build up sql by hand or for AR::Base::find conditions
      def localized_attribute_name(attribute_name)
        unless Locale.base?
          "#{attribute_name}_#{Locale.active.language.code}"
        else
          attribute_name.to_s
        end
      end
    end

    alias_method :localises, :localizes
  end

end

Step 3 – Localize your model


class Section << ActiveRecord::Base
  localizes :name
end

Done :)

What does this do?


>> saimon@iris ~/dev/myapp.org $ script/console test
Loading test environment.
>> Locale.set_base_language('ca-ES')
=> #<Globalize::RFC_3066:0xb6c9aea8 @locale="ca-ES", @language="ca", @country="ES">

>> Locale.set('ca-ES')
=> #<Globalize::Locale:0xb6c7d6dc @currency_decimal_sep=",", @currency_format="%n €", @code="ca-ES", @decimal_sep=",", @date_format=nil, @language=Catalan, @thousands_sep=".", @number_grouping_scheme=:western, @country=#<Globalize::Country:0xb6c61d9c @attributes={"date_format"=>nil, "currency_decimal_sep"=>",", "thousands_sep"=>".", "code"=>"ES", "number_grouping_scheme"=>"western", "english_name"=>"Spain", "decimal_sep"=>",", "id"=>"2", "currency_code"=>"EUR", "currency_format"=>"%n €"}>, @currency_code="EUR">

>> s = Section.find(:first)
=> #<Section:0xb6c38c08 @attributes={"name_en"=>"Home", "name"=>"Inici","name_fr"=>"Début", "id"=>"1", "name_es"=>"Inicio"}>

>> s.name
=> "Inici"

>> Locale.set('es-ES')
=> #<Globalize::Locale:0xb6befb98 @currency_decimal_sep=",", @currency_format="%n €", @code="es-ES", @decimal_sep=",", @date_format=nil, @language=Spanish, @thousands_sep=".", @number_grouping_scheme=:western, @country=#<Globalize::Country:0xb71f0ba0 @attributes={"date_format"=>nil, "currency_decimal_sep"=>",", "thousands_sep"=>".", "code"=>"ES", "number_grouping_scheme"=>"western", "english_name"=>"Spain", "decimal_sep"=>",", "id"=>"2", "currency_code"=>"EUR", "currency_format"=>"%n €"}>, @currency_code="EUR">

>> s.name
=> "Inicio"

>> Locale.set('en-US')
=> #<Globalize::Locale:0xb6ecfcc8 @currency_decimal_sep=nil, @currency_format="$%n", @code="en-US", @decimal_sep=".", @date_format="%m-%d-%y", @language=English, @thousands_sep=",", @number_grouping_scheme=nil, @country=#<Globalize::Country:0xb6e36050 @attributes={"date_format"=>"%m-%d-%y", "currency_decimal_sep"=>nil, "thousands_sep"=>",", "code"=>"US", "number_grouping_scheme"=>nil, "english_name"=>"United States", "decimal_sep"=>".", "id"=>"1", "currency_code"=>"USD", "currency_format"=>"$%n"}>, @currency_code="USD">

>> s.name
=> "Home"

>> Locale.set('fr-FR')
=> #<Globalize::Locale:0xb6d7e888 @currency_decimal_sep=",", @currency_format="%n €", @code="fr-FR", @decimal_sep=",", @date_format=nil, @language=French, @thousands_sep=nil, @number_grouping_scheme=:western, @country=#<Globalize::Country:0xb6d4964c @attributes={"date_format"=>nil, "currency_decimal_sep"=>",", "thousands_sep"=>nil, "code"=>"FR", "number_grouping_scheme"=>"western", "english_name"=>"France", "decimal_sep"=>",", "id"=>"3", "currency_code"=>"EUR", "currency_format"=>"%n €"}>, @currency_code="EUR">

>> s.name
=> "Début"

Advantages over Globalize’s current method for Model translations:

  • No playing around with find_every. ActiveRecord::Base::find is free to :include, :select all it wants.
  • This means that it’s not necessary to reload your model between locale changes.
  • Which means that you only ever get the one db hit, and you’ve got all your model translations.
  • If you’re in a non-base locale without a translation it returns the base locale attribute’s value.

Disadvantages

  • I’m still thinking of one… :)

Seriously though, if you come up with any drawbacks or shortcomings please let me know.

TODO:

  1. Automatically override the dynamic attribute finder mechanism (e.g. find_by_xxxx) to use the right field.
  2. make it configurable to allow returning of the first non-base locale translation if the base locale attribute is empty.

KUDOS: Inspired by Xavier Defrang’s article Playing with Rails I18N

[Update] – Just got todo number 1 done. :)


class ActiveRecord::Base

  class << self

    #Localize model attributes
    def localizes(*attribute_names)

      # parse out options hash
      options = attribute_names.pop if attribute_names.last.kind_of? Hash
      options ||= {}

      attribute_names_string = "[" + attribute_names.map {|attribute_name| ":#{attribute_name}"}.join(", ") + "]"
      class_eval %{
        @@localized_attributes = #{attribute_names_string}

        def self.localized_attributes
          @@localized_attributes
        end
      }

      attribute_names.each do |attribute_name|
        class_eval %{

          #Accessor that proxies to the right accessor for the current locale
          def #{attribute_name}
            unless Locale.base?
              localized_method = "#{attribute_name}_\#{Locale.active.language.code}"
              value = send(localized_method.to_sym) if respond_to?(localized_method.to_sym)
              return value ? value : read_attribute(:#{attribute_name})
            end
            read_attribute(:#{attribute_name})
          end

          #Accessor before typecasting that proxies to the right accessor for the current locale
          def #{attribute_name}_before_type_cast
            unless Locale.base?
              localized_method = "#{attribute_name}_\#{Locale.active.language.code}_before_type_cast"
              value = send(localized_method.to_sym) if respond_to?(localized_method.to_sym)
              return value ? value : read_attribute_before_type_cast('#{attribute_name}')
            end
            read_attribute_before_type_cast('#{attribute_name}')
          end

          #Write to appropriate localized attribute
          def #{attribute_name}=(value)
            unless Locale.base?
              localized_method = "#{attribute_name}_\#{Locale.active.language.code}"
              write_attribute(localized_method.to_sym, value) if respond_to?(localized_method.to_sym)
            else
              write_attribute(:#{attribute_name}, value)
            end
          end

          #Read base language attribute directly
          def _#{attribute_name}
            read_attribute(:#{attribute_name})
          end

          #Read base language attribute directly without typecasting
          def _#{attribute_name}_before_type_cast
            read_attribute_before_type_cast('#{attribute_name}')
          end

          #Write base language attribute directly
          def _#{attribute_name}=(value)
            write_attribute(:#{attribute_name}, value)
          end
        }
      end

      #Returns the localized_name of the supplied attribute for the
      #current locale
      #Useful when you have to build up sql by hand or for AR::Base::find conditions
      def localized_attribute_name(attribute_name)
        unless Locale.base?
          "#{attribute_name}_#{Locale.active.language.code}"
        else
          attribute_name.to_s
        end
      end

        # Enables dynamic finders like find_by_user_name(user_name) and find_by_user_name_and_password(user_name, password) that are turned into
        # find(:first, :conditions => ["user_name = ?", user_name]) and  find(:first, :conditions => ["user_name = ? AND password = ?", user_name, password])
        # respectively. Also works for find(:all), but using find_all_by_amount(50) that are turned into find(:all, :conditions => ["amount = ?", 50]).
        #
        # It's even possible to use all the additional parameters to find. For example, the full interface for find_all_by_amount
        # is actually find_all_by_amount(amount, options).
        def method_missing(method_id, *arguments)
          if match = /find_(all_by|by)_([_a-zA-Z]\w*)/.match(method_id.to_s)
            finder, deprecated_finder = determine_finder(match), determine_deprecated_finder(match)

            attribute_names = extract_attribute_names_from_match(match)
            super unless all_attributes_exists?(attribute_names)

            #Overrride attribute_names to use appropriate attribute name for current locale
            attribute_names.collect! {|attr_name| respond_to?(:localized_attributes) && localized_attributes.include?(attr_name.intern) ? localized_attribute_name(attr_name) : attr_name}

            attributes = construct_attributes_from_arguments(attribute_names, arguments)

            case extra_options = arguments[attribute_names.size]
              when nil
                options = { :conditions => attributes }
                set_readonly_option!(options)
                ActiveSupport::Deprecation.silence { send(finder, options) }

              when Hash
                finder_options = extra_options.merge(:conditions => attributes)
                validate_find_options(finder_options)
                set_readonly_option!(finder_options)

                if extra_options[:conditions]
                  with_scope(:find => { :conditions => extra_options[:conditions] }) do
                    ActiveSupport::Deprecation.silence { send(finder, finder_options) }
                  end
                else
                  ActiveSupport::Deprecation.silence { send(finder, finder_options) }
                end

              else
                ActiveSupport::Deprecation.silence do
                  send(deprecated_finder, sanitize_sql(attributes), *arguments[attribute_names.length..-1])
                end
            end
          elsif match = /find_or_(initialize|create)_by_([_a-zA-Z]\w*)/.match(method_id.to_s)
            instantiator = determine_instantiator(match)
            attribute_names = extract_attribute_names_from_match(match)
            super unless all_attributes_exists?(attribute_names)

            attributes = construct_attributes_from_arguments(attribute_names, arguments)
            options = { :conditions => attributes }
            set_readonly_option!(options)

            find_initial(options) || send(instantiator, attributes)
          else
            super
          end
        end
    end

    alias_method :localises, :localizes
  end

end

Επέστρεψε στο άρθρα