ぶちのブログ

競プロとCTFが趣味なWebエンジニアのアウトプットの場

Rubyで、Hashのkeyに対して再帰的に変換するクラスを、いい感じに実装してみた

はじめに

JSとRailsのデータの受け渡しの際に、Rubyは基本的に変数をsnake_caseで書くのに対し、JSではcamel_caseで書くことが多いと思います。
何らかの形で変換するか、どちらかのコードで混在してしまうことを妥協しないといけません。

今回は、Ruby側ですべて明示的に変換することにしました。
かなり普遍的に使えそうなコードが良い感じに書けたので、共有します。

もしよろしければ参考にしていただければ幸いです。

環境

ActiveSupportが導入されており、ruby2.6系ならば動くと思います。

実装

# frozen_string_literal: true

# NOTE: keyが衝突する場合の動作は未定義なので注意
# NOTE: keyはStringに、HashのサブクラスはHashに、それぞれ暗黙の型変換が起こる
class HashKeyConverter
  class << self
    def to_snake_case_recursively(object)
      key_change_recursively(object, ->(key) { key.to_s.underscore })
    end

    def to_camel_case_recursively(object, first_letter = :lower)
      key_change_recursively(object, ->(key) { key.to_s.camelize(first_letter) })
    end

    private

    def key_change_recursively(object, lambda_exp)
      if object.is_a?(Hash)
        object.to_h { |k, v| [lambda_exp.call(k), key_change_recursively(v, lambda_exp)] }
      elsif object.is_a?(Array)
        object.map { |e| key_change_recursively(e, lambda_exp) }
      else
        object
      end
    end
  end
end

解説

見たままではあるのですが、軽く解説します。

  • key_change_recursivelyというメソッドで、lambdaオブジェクトを再帰的にkeyに対して適用していきます
  • lambdaオブジェクト中のto_sはsymbolなどが与えられたときのための暗黙の型変換です(symbolは変換しないなどの場合は、lambdaオブジェクトの定義を変更します)
  • atcive_supportを使い慣れている人が使いやすいように、camelizeする際の引数で、lowerとupperを選べるようにしています

テストコード

動作がわかりにくい方のために、rspecで書いたテストも貼っておきます。(長いので折りたたんでいます)

テストコード

# frozen_string_literal: true

RSpec.describe HashKeyConverter do
  describe '#to_snake_case_recursively' do
    context 'HashでもArrayでもない場合' do
      res = HashKeyConverter.to_snake_case_recursively('hoge')
      it { expect(res).to eq 'hoge' }
    end

    context 'Hashの場合' do
      res = HashKeyConverter.to_snake_case_recursively(
        'hoge' => 'foo',
        'hoge_fuga' => 'foo_bar',
        'fugaHoge' => 'fooBar',
        'PiyoPiyo' => 'FooBar'
      )
      it do
        expect(res).to eq(
          'hoge' => 'foo',
          'hoge_fuga' => 'foo_bar',
          'fuga_hoge' => 'fooBar',
          'piyo_piyo' => 'FooBar'
        )
      end
    end

    context 'Hashのkeyが文字列以外の場合' do
      res = HashKeyConverter.to_snake_case_recursively(
        1 => true,
        true => 1,
        nil => false,
        hogeFuga: 'foo_bar'
      )
      it do
        expect(res).to eq(
          '1' => true,
          'true' => 1,
          '' => false,
          'hoge_fuga' => 'foo_bar'
        )
      end
    end

    context 'ネストされたハッシュの場合' do
      res = HashKeyConverter.to_snake_case_recursively(
        'hogeFuga' => {
          'hogeFuga' => {
            'hogeFuga' => {
              'hogeFuga' => 'foo_bar'
            }
          }
        }
      )
      it do
        expect(res).to eq(
          'hoge_fuga' => {
            'hoge_fuga' => {
              'hoge_fuga' => {
                'hoge_fuga' => 'foo_bar'
              }
            }
          }
        )
      end
    end

    context 'HashWithIndifferentAccessの場合' do
      res = HashKeyConverter.to_snake_case_recursively(
        { 'hogeFuga' => 1 }.with_indifferent_access
      )
      it { expect(res).to eq('hoge_fuga' => 1) }
    end

    context 'Arrayの場合' do
      res = HashKeyConverter.to_snake_case_recursively(
        [
          { 'hogeFuga' => 'foo_bar' },
          [1, true, nil],
          { 'hogeFuga' => [1, [[]], { 'hogeFuga' => [] }.with_indifferent_access] }
        ]
      )
      it do
        expect(res).to eq(
          [
            { 'hoge_fuga' => 'foo_bar' },
            [1, true, nil],
            { 'hoge_fuga' => [1, [[]], { 'hoge_fuga' => [] }] }
          ]
        )
      end
    end
  end

  describe '#to_camel_case_recursively' do
    context 'HashでもArrayでもない場合' do
      res = HashKeyConverter.to_camel_case_recursively('hoge')
      it { expect(res).to eq 'hoge' }
    end

    context 'Hashの場合' do
      res = HashKeyConverter.to_camel_case_recursively(
        'hoge' => 'foo',
        'hoge_fuga' => 'foo_bar',
        'fugaHoge' => 'fooBar',
        'PiyoPiyo' => 'FooBar'
      )
      it do
        expect(res).to eq(
          'hoge' => 'foo',
          'hogeFuga' => 'foo_bar',
          'fugaHoge' => 'fooBar',
          'piyoPiyo' => 'FooBar'
        )
      end
    end

    context 'Hashのkeyが文字列以外の場合' do
      res = HashKeyConverter.to_camel_case_recursively(
        1 => true,
        true => 1,
        nil => false,
        hoge_fuga: 'foo_bar'
      )
      it do
        expect(res).to eq(
          '1' => true,
          'true' => 1,
          '' => false,
          'hogeFuga' => 'foo_bar'
        )
      end
    end

    context 'ネストされたハッシュの場合' do
      res = HashKeyConverter.to_camel_case_recursively(
        {
          'hoge_fuga' => {
            'hoge_fuga' => {
              'hoge_fuga' => {
                'hoge_fuga' => 'foo_bar'
              }
            }
          }
        }, :upper
      )
      it do
        expect(res).to eq(
          'HogeFuga' => {
            'HogeFuga' => {
              'HogeFuga' => {
                'HogeFuga' => 'foo_bar'
              }
            }
          }
        )
      end
    end

    context 'HashWithIndifferentAccessの場合' do
      res = HashKeyConverter.to_camel_case_recursively(
        { 'hoge_fuga' => 1 }.with_indifferent_access
      )
      it { expect(res).to eq('hogeFuga' => 1) }
    end

    context 'Arrayの場合' do
      res = HashKeyConverter.to_camel_case_recursively(
        [
          { 'hoge_fuga' => 'foo_bar' },
          [1, true, nil],
          { 'hoge_fuga' => [1, [[]], { 'hoge_fuga' => [] }.with_indifferent_access] }
        ], :upper
      )
      it do
        expect(res).to eq(
          [
            { 'HogeFuga' => 'foo_bar' },
            [1, true, nil],
            { 'HogeFuga' => [1, [[]], { 'HogeFuga' => [] }] }
          ]
        )
      end
    end
  end
end