2017-04-06

ActiveRecordのint型項目の最大値超過エラーについて

ActiveRecordでinteger型の項目をマイグレして、int型の最大値以上の数を入れて保存しようとすると、モデル側で以下のエラーが出ます。

ActiveModel::RangeError: 2147483648 is out of range for ActiveModel::Type::Integer with limit 4

最大値のエラーなのでエラー自体には納得なんですが、この手のエラーはバリデーションでerrorsに格納してほしいなーと思い、ソースコードを読んでみました。

新規作成時はdatabase_statement.rbのinsertメソッドが呼び出され、

とメソッドが呼ばれていきます。

def insert(arel, name = nil, pk = nil, id_value = nil, sequence_name = nil, binds = [])
  sql, binds, pk, sequence_name = sql_for_insert(to_sql(arel, binds), pk, id_value, sequence_name, binds)
  value = exec_insert(sql, name, binds, pk, sequence_name)
  id_value || last_inserted_id(value)
end
# Converts an arel AST to SQL
def to_sql(arel, binds = [])
  if arel.respond_to?(:ast)
    collected = visitor.accept(arel.ast, collector)
    collected.compile(binds.dup, self)
  else
    arel
  end
end
class BindCollector < Arel::Collectors::Bind
  def compile(bvs, conn)
    casted_binds = conn.prepare_binds_for_database(bvs)
    super(casted_binds.map { |value| conn.quote(value) })
  end
end
def prepare_binds_for_database(binds) # :nodoc:
  binds.map(&:value_for_database)
end
def value_for_database
  type.serialize(value)
end

binds変数にはQueryAttributeの配列が入ります。int型の項目の場合はQueryAttributeオブジェクトのtypeにはType::Integer型のオブジェクトが入ります。このType::Integerのserializeでrangeチェックをしていて、range外の場合はRangeErrorがraiseされます。

DEFAULT_LIMIT = 4

def serialize(value)
  result = cast(value)
  if result
    ensure_in_range(result)
  end
  result
end

def ensure_in_range(value)
  unless range.cover?(value)
    raise ActiveModel::RangeError, "#{value} is out of range for #{self.class} with limit #{_limit}"
  end
end

def initialize(*)
  super
  @range = min_value...max_value
end

def max_value
  1 << (_limit * 8 - 1) # 8 bits per byte with one bit for sign
end

def min_value
  -max_value
end

def _limit
  self.limit || DEFAULT_LIMIT
end

MySQLの場合は以下のTypeMapの設定によってMySQLの型からRubyオブジェクトのType::XXXに変換されます。intの場合はlimit: 4、bigintの場合はlimit: 8のように、項目によってrangeの桁数が変わります。MySQLの項目型の判定は正規表現マッチを使っています。

def initialize_type_map(m) # :nodoc:
  super

  register_class_with_limit m, %r(char)i, MysqlString

  m.register_type %r(tinytext)i,   Type::Text.new(limit: 2**8 - 1)
  m.register_type %r(tinyblob)i,   Type::Binary.new(limit: 2**8 - 1)
  m.register_type %r(text)i,       Type::Text.new(limit: 2**16 - 1)
  m.register_type %r(blob)i,       Type::Binary.new(limit: 2**16 - 1)
  m.register_type %r(mediumtext)i, Type::Text.new(limit: 2**24 - 1)
  m.register_type %r(mediumblob)i, Type::Binary.new(limit: 2**24 - 1)
  m.register_type %r(longtext)i,   Type::Text.new(limit: 2**32 - 1)
  m.register_type %r(longblob)i,   Type::Binary.new(limit: 2**32 - 1)
  m.register_type %r(^float)i,     Type::Float.new(limit: 24)
  m.register_type %r(^double)i,    Type::Float.new(limit: 53)
  m.register_type %r(^json)i,      MysqlJson.new

  register_integer_type m, %r(^bigint)i,    limit: 8
  register_integer_type m, %r(^int)i,       limit: 4
  register_integer_type m, %r(^mediumint)i, limit: 3
  register_integer_type m, %r(^smallint)i,  limit: 2
  register_integer_type m, %r(^tinyint)i,   limit: 1

  m.register_type %r(^tinyint\(1\))i, Type::Boolean.new if emulate_booleans
  m.alias_type %r(year)i,          'integer'
  m.alias_type %r(bit)i,           'binary'

  m.register_type(%r(enum)i) do |sql_type|
    limit = sql_type[/^enum\((.+)\)/i, 1]
      .split(',').map{|enum| enum.strip.length - 2}.max
    MysqlString.new(limit: limit)
  end

  m.register_type(%r(^set)i) do |sql_type|
    limit = sql_type[/^set\((.+)\)/i, 1]
      .split(',').map{|set| set.strip.length - 1}.sum - 1
    MysqlString.new(limit: limit)
  end
end

項目の型はSHOW FULL FIELDS FROMで取得しています。

def column_definitions(table_name) # :nodoc:
  execute_and_free("SHOW FULL FIELDS FROM #{quote_table_name(table_name)}", 'SCHEMA') do |result|
    each_hash(result)
  end
end
このエントリーをはてなブックマークに追加