ハッシュに対してドットアクセスできる機能を追加しようと調べたところ、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::OrderedOptions
や ActiveSupport::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にも同様のコメントがありました。そのため、既存のハッシュのキーが文字列の場合には使えなさそうです。
- ActiveSupport::InheritableOptions does not work with string keys · Issue #28454 · rails/rails
- Fix Style/OpenStructUse offenses in auth provider specs (!75277) · Merge requests · GitLab.org / GitLab · GitLab
- Fix Style/OpenStructUse offenses for import service and spec helpers (!75280) · Merge requests · GitLab.org / GitLab · 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