現象
Rails5 でActionController::Parameters#fetch の返り値に ActionController::Parameters#[]= を使用しても Hash の値を変更できないという現象です。
hash = ActiveSupport::HashWithIndifferentAccess.new({ hash_key: "original" })
params = ActionController::Parameters.new({ param_key: hash })
params.fetch(:param_key)[:hash_key] = "modified"
params.fetch(:param_key)[:hash_key]
# => "original"
Rails4 では ActionController::Parameters#fetch の返り値に ActionController::Paramers#[]= を使用すると Hash の値を変更できていました。
hash = ActiveSupport::HashWithIndifferentAccess.new({ hash_key: "original" })
params = ActionController::Parameters.new({ param_key: hash })
params.fetch(:param_key)[:hash_key] = "modified"
params.fetch(:param_key)[:hash_key]
# => "modified"
開発環境
Rails5: v5.0.7.2
Rails4: v4.2.11.1
解決できた方法
fetch ではなく [] で実装する方法
ActionController::Parameters#fetch を使わずに ActionController::Parameters#[] を使用すると、 params の値を変更することができました。
hash = ActiveSupport::HashWithIndifferentAccess.new({ hash_key: "original" })
params = ActionController::Parameters.new({ param_key: hash })
params[:param_key][:hash_key] = "modified"
params[:param_key][:hash_key]
# => "modified"
背景
Rails4 から Rails5 にアップデートしたところ、 ActionController::Parameters の fetch と []= を使って params の値を変更する箇所が、うまく動作しないことに気づきました。
params.fetch(:schedule)[:start_at] = DateTime.parse("#{date} #{params.dig(:schedule, :start_at).in_time_zone('Tokyo')} JST")
Rails5 で ActionController::Parameters の実装が変わったことがきっかけではないかと考え、 Rails の内部実装を調査することにしました。
調査結果
Rails5 だと fetch の返り値に []= を実行しても参照元の値を変更できないことがある
-
fetch(key)で参照しようとした値がHashだった場合、-
HashをActionController::Parametersのインスタンスに変換して返します - 返ってきたインスタンスに対して
[]=を実行しても参照元の値は変更されません
-
def fetch(key, *args)
convert_value_to_parameters(
@parameters.fetch(key) {
if block_given?
yield
else
args.fetch(0) { raise ActionController::ParameterMissing.new(key) }
end
}
)
end
ActionController::Parameters#fetch では ActiveSupport::HashWithIndifferentAccess クラスのインスタンスである @parameters に対して Hash#fetch メソッドが実行されます。
そして、その返り値を引数として ActionController::Parameters#convert_value_to_parameters が実行されます。
def convert_value_to_parameters(value)
case value
when Array
return value if converted_arrays.member?(value)
converted = value.map { |_| convert_value_to_parameters(_) }
converted_arrays << converted
converted
when Hash
self.class.new(value)
else
value
end
end
ActionController::Parameters#convert_value_to_parameters の引数に渡ってくる値のクラスが ActiveSupport::HashWithIndifferentAccess である場合、そのクラスは Hash を継承しているので、 self.class.new(value) が実行されます。
hash = ActiveSupport::HashWithIndifferentAccess.new({ hash_key: "original" })
params = ActionController::Parameters.new({ param_key: hash })
params.fetch(:param_key)[:hash_key] = "modified"
params.fetch(:param_key)[:hash_key]
=> "original"
params[:param_key][:hash_key]
=> "original"
上記の調査を元にサンプルコードを説明すると、 params.fetch(:param_key) の返り値は ActionController::Parameters.new({ hash_key: "original"}) になります。
上記で作成された ActionController::Parameters のインスタンスは params とは別のインスタンスなので、 [:hash_key] = "modified" を実行しても params の値は変更できません。
[] の返り値に []= を実行すれば参照元の値を変更できる
-
[](key)で参照しようとした値がHashの場合、-
HashをActionController::Parametersのインスタンスに変換して返します - 変換した値を参照元の
Hashに再代入します - 再代入されているため、返ってきたインスタンスに
[]=を実行すると参照元の値も変更できます
-
def [](key)
convert_hashes_to_parameters(key, @parameters[key])
end
ActionController::Parameters#[] では ActionController::Parameters#convert_hashes_to_parameters が実行されます。
def convert_hashes_to_parameters(key, value)
converted = convert_value_to_parameters(value)
@parameters[key] = converted unless converted.equal?(value)
converted
end
[](key) で参照しようとした値が Hash だった場合、 その Hash が ActionController::Parameters#convert_value_to_parameters によって、 ActionController::Parameters のインスタンスに変換されます。
さらに変換されたインスタンスを参照元の値に再代入した後に、そのインスタンスを返します。参照元に再代入しているため、返ってきたインスタンスの値を変更すると参照元の値も変更されます。
hash = ActiveSupport::HashWithIndifferentAccess.new({ hash_key: "original" })
params = ActionController::Parameters.new({ param_key: hash })
params[:param_key][:hash_key] = "modified"
params[:param_key][:hash_key]
=> "modified"
上記の調査を元にサンプルコードを説明します。 params[:param_key] を実行することで、 params[:param_key] の値が ActionController::Parameters のインスタンスに変換され、さらに params[:param_key] に変換されたインスタンスが再代入されます。
ActionController::Parameters のインスタンスは params[:param_key] に再代入されているため、 params[:param_key] で返ってきたインスタンスに対して [:hash_key] = "modified" を実行すると、 params の値も変更されます。
備考
Hash ではなく ActionController::Parameters であれば fetch でも変更できる
例えば、以下のサンプルコードのように一度 ActionController::Parameters#[] を実行してしまえば、 params.fetch[:param_key][:hash_key] = "modified" でも params の値を変更することができます。
hash = ActiveSupport::HashWithIndifferentAccess.new({ hash_key: "original" })
params = ActionController::Parameters.new({ param_key: hash })
params[:param_key] # ここで params[:param_key] に ActionController::Parameters に変換されたインスタンスが再代入される
params.fetch(:param_key)[:hash_key] = "modified"
params[:param_key][:hash_key]
=> "modified"
まず params[:param_key] が実行されることで、 params[:param_key] に ActionController::Parameters のインスタンスが再代入されます。
つぎに params.fetch(:param_key) を実行すると、内部で convert_value_to_parameters が実行されるわけですが、 ここで引数に渡ってくる値は ActiveSupport::HashWithIndifferentAccess ではなく、 ActionController::Parameters のインスタンスになります。
def convert_value_to_parameters(value)
case value
when Array
return value if converted_arrays.member?(value)
converted = value.map { |_| convert_value_to_parameters(_) }
converted_arrays << converted
converted
when Hash
self.class.new(value)
else
value
end
end
よって、 params.fetch(:param_key) の返り値は params[:param_key] に再代入された ActionController::Parameters のインスタンスなので、 params.fetch(:param_key)[:hash_key] = "modified" を実行すれば、 params の値も変更されます。