1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Ransackで大きな数値を入力したときに発生するActiveModel::RangeErrorを回避する方法

Last updated at Posted at 2022-06-10

解決したい問題

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エラーになってしまう)のは困るので、以下のようなモンキーパッチを当ててエラーを出さないようにしました。

config/initializers/ransack.rb
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経由で呼ばれたときだけ パッチが当たるようにいろいろと工夫を入れています。

上のコードに細かくコメントを付けていくとこんな感じになります。

config/initializers/ransack.rb
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は起きなくなるはずです。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?