解決したい問題
Ransackでは大きな数値を検索条件に入れるとActiveModel::RangeError
が発生します。
User.ransack(age_eq: 123456789012345678901).result
#=> ActiveModel::RangeError: 123456789012345678901 is out of range for ActiveRecord::ConnectionAdapters::SQLite3Adapter::SQLite3Integer with limit 8 bytes
SQLite3やPostgreSQLで確認する限り、SQL上は検索条件として大きな数値を入力してもエラーにはならないようです。
-- (データが見つかるかどうかは別として)エラーにはならない
SELECT *
FROM users
WHERE age = 123456789012345678901
またActiveRecordもエラーにはならないようになっています。
# どちらの書き方でもエラーにはならない
User.where(age: 123456789012345678901)
User.where("age = ?", 123456789012345678901)
ちなみに、ActiveRecordの場合、age: 123456789012345678901
で検索すると処理が中断されてDBにSQLが発行されないという少し変わった挙動になっています。詳しくは以下のPRをご覧ください。
この問題の解決策
Ransackで検索したときだけエラーになる(Railsでいうと500エラーになってしまう)のは困るので、以下のようなモンキーパッチを当ててエラーを出さないようにしました。
module RangeErrorSafe
def arel_predicate
predicate = super
if replace_right_node?(predicate)
plain_value = predicate.right.value
predicate.right = plain_value
end
predicate
end
private
def replace_right_node?(predicate)
return false unless predicate.is_a?(Arel::Nodes::Binary)
arel_node = predicate.right
return false unless arel_node.is_a?(Arel::Nodes::Casted)
relation, name = arel_node.attribute.values
attribute_type = relation.type_for_attribute(name).type
attribute_type == :integer && arel_node.value.is_a?(Integer)
end
end
Ransack::Nodes::Condition.prepend(RangeErrorSafe)
ここでやっているのはActiveModel::RangeError
を発生しうるArel::Nodes::Casted
から、ただの整数値(Integer型の値)への置き換えです(predicate.right = plain_value
がその該当部分です)。
ただし、何でもかんでも置き換えるとまずいので、should_use_plain_value?
メソッドで置き換えを行うべきかどうかを判定しています。具体的には検索対象となるカラムのデータ型と、入力値がどちらも整数値の場合のみ、置き換えを行います。
この問題の(古い)解決策
以下は古い解決策なので、参考にしなくても大丈夫です。
Ransackで検索したときだけエラーになる(Railsでいうと500エラーになってしまう)のは困るので、以下のようなモンキーパッチを当ててエラーを出さないようにしました。
module RangeErrorSafe
def arel_predicate
predicate = super
if (arel_node = pluck_hackable_node(predicate))
def arel_node.value_for_database
super
rescue ActiveModel::RangeError
value
end
end
predicate
end
private
def pluck_hackable_node(predicate)
return unless predicate.respond_to?(:right)
arel_node = predicate.right
return unless arel_node.is_a?(Arel::Nodes::Casted)
relation_type = arel_node.attribute.relation.type_for_attribute(arel_node.attribute.name).type
both_integer = relation_type == :integer && arel_node.value.is_a?(Integer)
arel_node if both_integer
end
end
Ransack::Nodes::Condition.prepend(RangeErrorSafe)
このパッチでやっていることを端的に言うと、「ActiveModel::RangeError
が発生しても無視して処理を進める(つまりDBにクエリを投げてその結果を返す)」になります(rescue ActiveModel::RangeError
に注目)。
ただ、エラーを出しているのはRansackではなくActiveModel::Type::Integer
クラスであり(参考)、このクラスに直接パッチを当てるとRails全体の挙動に影響を与えてしまうため、 Ransack経由で呼ばれたときだけ パッチが当たるようにいろいろと工夫を入れています。
上のコードに細かくコメントを付けていくとこんな感じになります。
module RangeErrorSafe
# Ransack::Nodes::Condition#arel_predicateにモンキーパッチを当てる
def arel_predicate
# オリジナル実装の実行結果を受け取る(このモジュールはprependされる想定)
predicate = super
# 処理対象のnodeであれば、オリジナル実装を書き替える
if (arel_node = pluck_hackable_node(predicate))
# インスタンス変数に対して特異メソッドを定義する(モンキーパッチを当てる)
# こうすることでRansack経由で呼ばれたインスタンスに対してのみ、このパッチを適用する
def arel_node.value_for_database
# オリジナル実装の値を返す(エラーが起きなければ)
super
rescue ActiveModel::RangeError
# エラーが起きても続行可能と判断して入力値(たとえば 123456789012345678901 )をそのまま返す
value
end
end
predicate
end
private
# predicateに処理対象のnodeがあればそのnodeを返す、そうでなければnilを返す
def pluck_hackable_node(predicate)
# rightメソッドがなければ対象外
return unless predicate.respond_to?(:right)
# Arel::Nodes::Castedでなければ対象外
arel_node = predicate.right
return unless arel_node.is_a?(Arel::Nodes::Casted)
# 検索するカラムのデータ型も入力された値もどちらもIntegerであれば処理対象とする
relation_type = arel_node.attribute.relation.type_for_attribute(arel_node.attribute.name).type
both_integer = relation_type == :integer && arel_node.value.is_a?(Integer)
arel_node if both_integer
end
end
# オリジナルの実装を利用しながらモンキーパッチを当てたいのでprependを使う
Ransack::Nodes::Condition.prepend(RangeErrorSafe)
確認環境
このコードは以下の環境で確認しました。
- Rails 7.0.3
- Ransack 3.1.2
- SQLite3 and PostgreSQL
でも、こんな大きな数値を入力することってあるの?
ふつうに使ってるぶんにはないと思います。
この対応が必要になったのは外部のテスターさんが実施したテストで「大きな値を指定すると500エラーが起きる」という報告が挙がったためです。
400系のエラーを返すならまだしも、500エラーはちょっとよろしくないだろう、ということで対応を検討することになりました。
ちなみに最初はなぜエラーが出るのか全然わからず、RailsとRansackのコードを行ったり来たりしながら調査してようやく原因を特定できたものの、今度はそれをうまく修正する方法がわからず、なんだかんだで丸1日以上かけてこの記事で書いたような解決策に至りました😅
関連issue
本来であればRansack本体で対応されるのが望ましいため、以下にissueを立ててあります。
採用されるかどうかわかりませんが、PRも作っておきました。
→(2022.6.12追記)無事にPRがマージされました!🙌
これがリリースされればActiveModel::RangeErrorは起きなくなるはずです。