Rails + ActiveSupport::InheritableOptionsを使って、既存のハッシュをドットアクセスできるようにする

ハッシュに対してドットアクセスできる機能を追加しようと調べたところ、Rubyの標準ライブラリに OpenStruct がありました。

ためしに使ってみたところ、以下のようになりました。

require 'ostruct'

o = OpenStruct.new({foo: 'bar'})

puts o.foo
#=> bar

 
ただ、Rubyのドキュメントを読むと、注意事項がいくつか記載されていました。
https://docs.ruby-lang.org/en/3.2/OpenStruct.html#class-OpenStruct-label-Caveats

また、ドキュメントの文末には

For all these reasons, consider not using OpenStruct at all.

とありました。

 
そのため、Rails環境下でも使えそうな OpenStruct の代替を調べたところ、 ActiveSupport::OrderedOptionsActiveSupport::InheritableOptions がありました。
https://github.com/rails/rails/blob/main/activesupport/lib/active_support/ordered_options.rb

 
そこで、ActiveSupport::InheritableOptions を試してみたので、メモを残します。

 
目次

 

環境

 
なお、今回はRSpecで動作確認するため、プロダクションコードはヘルパーの中に書くことにします。

 

ハッシュの挙動確認

ActiveSupport::InheritableOptions を使う前に、まずはハッシュの挙動を確認します。

Rubyのハッシュでは、キーに

  • 文字列
  • 数値
  • シンボル

が使えるため、それぞれ試してみます。

 

文字列をキーとしたハッシュ

例えば以下のようなハッシュです。

def hash_with_string_key
  { 'apple' => 'シナノゴールド' }
end

 
キーを文字列とした場合、文字列を指定したときは値を取得できる一方、シンボルを指定した場合は値を取得できません。

context '#hash_with_string_key' do
  context '文字列を指定' do
    it '値が取得できること' do
      actual = helper.hash_with_string_key

      expect(actual['apple']).to eq('シナノゴールド')
    end
  end

  context 'シンボルを指定' do
    it '値が取得できず、nilが返ること' do
      actual = helper.hash_with_int_key

      expect(actual[:apple]).to eq(nil)
    end
  end
end

 

数字をキーとしたハッシュ

例えば以下のようなハッシュです。

def hash_with_int_key
  { 1 => 'シナノゴールド' }
end

 
この場合、数値を使ってハッシュから値を取得できます。

context '#hash_with_int_key' do
  context '数字を指定' do
    it '値が取得できること' do
      actual = helper.hash_with_int_key

      expect(actual[1]).to eq('シナノゴールド')
    end
  end

  context '文字列を指定' do
    it '値が取得できず、nilが返ること' do
      actual = helper.hash_with_int_key

      expect(actual['1']).to eq(nil)
    end
  end
end

 

シンボルをキーとしたハッシュ

例えば以下のようなハッシュです。

def hash_with_symbol_key
  { apple: 'シナノゴールド' }
end

 
この場合、シンボルで値を取得できます。

context '#hash_with_symbol_key' do
  context '文字列を指定' do
    it '値が取得できず、nilが返ること' do
      actual = helper.hash_with_symbol_key

      expect(actual['apple']).to eq(nil)
    end
  end

  context 'シンボルを指定' do
    it '値が取得できること' do
      actual = helper.hash_with_symbol_key

      expect(actual[:apple]).to eq('シナノゴールド')
    end
  end
end

 

シンボルと文字列をキーとしたハッシュ

例えば以下のようなハッシュです。

def hash_with_string_and_symbol_key
  {
    'apple' => 'ふじ',
    apple: 'シナノゴールド'
  }
end

 
この場合、シンボルと文字列で別の値が取得できます。

context '#hash_with_string_and_symbol_key' do
  it '文字列とシンボルで別の値が取得できること' do
    actual = helper.hash_with_string_and_symbol_key

    expect(actual['apple']).to eq('ふじ')
    expect(actual[:apple]).to eq('シナノゴールド')
  end
end

 

ActiveSupport::InheritableOptionsの挙動確認

元となるハッシュの挙動が確認できたので、次は ActiveSupport::InheritableOptions の挙動を確認します。

なお、元のハッシュがフラット・ネストで挙動が変わるので、それぞれ確認します。

 

元のハッシュがフラットな場合

キーが文字列のハッシュを元にした場合、値が取得できない

こんな感じで生成します。

def inheritable_with_string_key
  ActiveSupport::InheritableOptions.new(hash_with_string_key)
end

 
動作確認すると、以下のテストがパスしました。

元のハッシュには値があるのにも関わらず、 ActiveSupport::InheritableOptions からは値を取得できないようです。

context '#inheritable_with_string_key' do
  it '文字列を指定しても値が取得できず、nilが返ること' do
    actual = helper.inheritable_with_string_key

    expect(actual.apple).to eq(nil)
  end
end

 
GithubのissueやGitLabにも同様のコメントがありました。そのため、既存のハッシュのキーが文字列の場合には使えなさそうです。

 

キーが数値のハッシュを元にした場合、シンタックスエラーになる

こんな感じで生成します。

def inheritable_with_int_key
  ActiveSupport::InheritableOptions.new(hash_with_int_key)
end

 
動作確認すると、テストを走らせる前にシンタックスエラーで落ちました。

it '1を指定するとSyntaxErrorになるのでコメントアウトする' do
  actual = helper.inheritable_with_int_key

  # SyntaxError
  # no .<digit> floating literal anymore; put 0 before dot
  # expect(actual.1).to eq(nil)

  expect(1).to eq(1)
end

 
キーが数値のハッシュに対しても使えないようです。

 

キーがシンボルのハッシュを元にする場合、値が取得できる

こんな感じで生成します。

def inheritable_with_symbol_key
  ActiveSupport::InheritableOptions.new(hash_with_symbol_key)
end

 
動作確認すると値が取れました。

context '#inheritable_with_symbol_key' do
  it '値が取得できること' do
    actual = helper.inheritable_with_symbol_key

    expect(actual.apple).to eq('シナノゴールド')
  end
end

 

キーに同じ名前のシンボルと文字列があるハッシュを元にする場合、シンボルの値を取得できる

こんな感じで生成します。

def inheritable_with_string_and_symbol_key
  ActiveSupport::InheritableOptions.new(hash_with_string_and_symbol_key)
end

 
動作確認すると、キーがシンボルの方の値を取得できました。

context '#inheritable_with_string_and_symbol_key' do
  it 'シンボルの値が取得できること' do
    actual = helper.inheritable_with_string_and_symbol_key

    expect(actual.apple).to eq('シナノゴールド')
  end
end

 

元のハッシュがネストしている場合

例えば、こんな感じでハッシュがネストしているとき、 ActiveSupport::InheritableOptions を使うとどうなるか動作確認をしていきます。

def nested_hash_with_symbol_key
  {
    fruit: {
      apple:  {
        name: 'シナノゴールド',
        area: [
          { name: '青森県' },
          { name: '長野県' },
        ]
      }
    }
  }
end

ネストしているキーにドットでアクセスするとエラーになる

こんな感じで生成します。

def nested_inheritable_with_symbol_key
  ActiveSupport::InheritableOptions.new(nested_hash_with_symbol_key)
end

 
ネストしているキー fruit.apple.name にアクセスすると、エラーになります。

ActiveSupport::InheritableOptions を単に使うだけでは、一番上の階層だけが InheritableOptions になるものの、その下の階層以降はハッシュのままのようです。

context 'ドットでアクセス' do
  it 'ネストしているものはHashのままなので、エラーになること' do
    actual = helper.nested_inheritable_with_symbol_key

    expect { actual.fruit.apple.name }.to raise_error(
      "undefined method `apple' for {:apple=>{:name=>\"シナノゴールド\", :area=>[{:name=>\"青森県\"}, {:name=>\"長野県\"}]}}:Hash")
  end
end

 
そのため、1階層目をドット、2階層目以降をハッシュのようにアクセスすると、値を取得できます。

context '一階層目はドット、それ以降はハッシュとしてアクセス' do
  it '値が取得できること' do
    actual = helper.nested_inheritable_with_symbol_key

    expect(actual.fruit[:apple][:name]).to eq('シナノゴールド')
  end
end

 

ネストしている場合に、再帰的に InheritableOptions を適用すると動作する

ネストしている場合に使えなくなるのは不便なので、何か方法がないかを調べたところ、Railsのプルリクに似たような実装がありました。
Allow access to nested secrets by method calls by ghiculescu · Pull Request #42106 · rails/rails

 
そこで似たような実装 nested_inheritable_with_hash_transform を作って試してみます。

def nested_inheritable_with_hash_transform
  ActiveSupport::InheritableOptions.new(hash_transform(nested_hash_with_symbol_key))
end

private def hash_transform(hash)
  hash.transform_values { |value| value.is_a?(Hash) ? ActiveSupport::InheritableOptions.new(hash_transform(value)) : value }
end

 
すると、チェーンの最後までハッシュであったとしても、値を取得できるようになりました。

context 'チェーンの最後までHash' do
  it '値が取得できること' do
    actual = helper.nested_inheritable_with_hash_transform

    expect(actual.fruit.apple.name).to eq('シナノゴールド')
  end
end

 

ネストしている途中で、Arrayがあった場合にも対応できるようにする

以上で解決したかと思いきや

def nested_hash_with_symbol_key
  {
    fruit: {
      apple:  {
        name: 'シナノゴールド',
        area: [
          { name: '青森県' },
          { name: '長野県' },
        ]
      }
    }
  }
end

のようなハッシュがあった時に fruit.apple.area[0].name で取得しようとするとエラーになります。

Arrayがあったときは InheritableOptions にならないためです。

context 'チェーンの途中にHash以外(Array)がある' do
  it 'Hash以外の項目がはさまるとHashのままなので、エラーになること' do
    expect do
      actual = helper.nested_inheritable_with_hash_transform
      actual.fruit.apple.area[0].name
    end.to raise_error("undefined method `name' for {:name=>\"青森県\"}:Hash")
  end
end

 
そこで、Arrayのときも再帰的に対応できるよう、 nested_inheritable_with_hash_array_transform を作成します。

def nested_inheritable_with_hash_array_transform
  ActiveSupport::InheritableOptions.new(hash_array_transform(nested_hash_with_symbol_key))
end

private def hash_array_transform(value)
  value.transform_values do |v|
    if v.is_a?(Hash)
      ActiveSupport::InheritableOptions.new(hash_array_transform(v))

    elsif v.is_a?(Array)
      v.map do |nested_v|
        ActiveSupport::InheritableOptions.new(hash_array_transform(nested_v))
      end

    else
      v
    end
  end
end

 
これで、途中にArrayがあったとしても動作するようになりました。

context 'ドットでアクセス' do
  context 'チェーンの最後までHash' do
    it '値が取得できること' do
      actual = helper.nested_inheritable_with_hash_transform

      expect(actual.fruit.apple.name).to eq('シナノゴールド')
    end
  end

  context 'チェーンの途中にHash以外(Array)がある' do
    it '値が取得できること' do
      actual = helper.nested_inheritable_with_hash_array_transform
      puts("#{title}: #{actual.fruit.apple.area}")

      expect(actual.fruit.apple.area[0].name).to eq('青森県')
    end
  end
end

 

JSON.parseのobject_classにActiveSupport::OrderedOptionsを指定しても対応可能

ネストしたハッシュの時にうまく動作しないのは OpenStruct でも同様です。

そのため、OpenStruct のときの解決方法も同様に使えるのではないかと考え調べてみたところ、JSON.parse を使う方法がありました。
ネストしたHashからOpenStructを作る - 世界中の羊をかき集めて

 
そこで、 ActiveSupport::InheritableOptions でも使えるか試してみます。

今回、 JSON.parse の引数 object_class には、 InheritableOptions の親クラスである OrderedOptions を指定します。

def nested_inheritable_with_json_parse
  JSON.parse(nested_hash_with_symbol_key.to_json, object_class: ActiveSupport::OrderedOptions)
end

 
動作確認すると、JSON.parse でも問題なく動作しました。

context 'ドットでアクセス' do
  context 'チェーンの最後までHash' do
    it '値が取得できること' do
      actual = helper.nested_inheritable_with_json_parse

      expect(actual.fruit.apple.name).to eq('シナノゴールド')
    end
  end

  context 'チェーンの途中にHash以外(Array)がある' do
    it '値が取得できること' do
      actual = helper.nested_inheritable_with_json_parse
      puts("#{title}: #{actual.fruit.apple.area}")

      expect(actual.fruit.apple.area[0].name).to eq('青森県')
    end
  end
end

 
以上より、OpenStruct の代わりに ActiveSupport::InheritableOptions を使うことでもドットアクセスが可能なようでした。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/rails_7_0_minimal_app

今回のプルリクはこちら。
https://github.com/thinkAmi-sandbox/rails_7_0_minimal_app/pull/8