PIYO - Tech & Life -

RubyでネストしたHashのキーをフルパス?にする方法

Ruby Hash

RubyでYAMLファイルを読むとネストしたHashにできます。

a:
  b:
    c1: hogehoge
    c2: fugafuga

であればこうなります。

require 'yaml'
hash = YAML.load_file('さっきのyml')

# hashは↓と同じものになる
{
  a: {
    b: {
      c1: 'hogehoge',
      c2: 'fugafuga',
    }
  }
}

ところでRailsの多言語対応のための仕組みI18nではYAMLを使いますよね。で、I18n.l('activerecord.errors.messages.record_invalid')などとして、キーを渡すと翻訳が返ってきます。

このキーの部分をドットでsplitしてYAMLを読み込んだHashを辿れば目的の翻訳にたどり着けるわけですが、その逆の全てのキーを結合してフルパスみたいにしたHashが欲しくなったのでどうやってやるのかなーと調べました。

(そもそもなんて呼んだらいいのかわからないので、便宜的にフルパスとでも呼んでおきます。)

つまり、↓のようなYAMLファイルがあったら(rails-i18nのja.ymlを拝借しています)、

ja:
  activerecord:
    errors:
      messages:
        record_invalid: "バリデーションに失敗しました: %{errors}"
        restrict_dependent_destroy:
          has_one: "%{record}が存在しているので削除できません"
          has_many: "%{record}が存在しているので削除できません"

↓こうなってほしいのです。

{
  "ja.activerecord.errors.messages.record_invalid" => "バリデーションに失敗しました: %{errors}",
  "ja.activerecord.errors.messages.restrict_dependent_destroy.has_one" => "%{record}が存在しているので削除できません",
  "ja.activerecord.errors.messages.restrict_dependent_destroy.has_many" => "%{record}が存在しているので削除できません",
}

調べてみてさっとできそうな方法がわからなかったので、再起的に書いてみることにしました。

module HashKeyJoiner
  module_function

  def join(hash, divider = '.')
    h = {}
    each_path(hash.dup, '', divider) do |path, value|
      h[path] = value
    end
    h
  end

  def each_path(object, path, divider = '.', &block)
    if object.is_a?(Hash)
      object.each do |key, value|
        div = path.empty? ? '' : divider
        next_path = [path, key].join(div)
        each_path value, next_path, divider, &block
      end
    else
      yield path, object
    end
  end
end

Hashを拡張するのもよいのですが、専用のモジュールにして呼ぶだけにしておきました。

Hashを辿りながら、次の子要素がHashであれば再帰処理、終端の文字列であれば新しいHashに値を追加をしています。途中経過のpathを結合していきながらたどっているので、キーのフルパスで新しいHashを作れます。

↓のように呼び出すと、

yaml = YAML.load_file('./ja.yml')
puts HashKeyJoiner.join(yaml)

↓のようにHash化されます。

{
  "ja.activerecord.errors.messages.record_invalid"=>"バリデーションに失敗しました: %{errors}",
  "ja.activerecord.errors.messages.restrict_dependent_destroy.has_one"=>"%{record}が存在しているので削除できません",
  "ja.activerecord.errors.messages.restrict_dependent_destroy.has_many"=>"%{record}が存在しているので削除できません",
  ...
}

使いみちはあまりなさそうですが、ひとまず僕の目的は達成。