現象
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
の値も変更されます。