🪚

カラムの許容文字数に合わせて文字列を切る

2023/06/01に公開

カラムの許容文字数より大きければ切っているはずなのに本番で Data too long やら Incorrect string value がでるのでその原因と対策。

許容文字数で切る問題のコード

require "active_record"
system "mysql -u root -e 'drop database if exists __test__; create database __test__'"
ActiveRecord::Base.establish_connection(adapter: "mysql2", charset: "utf8mb4", collation: "utf8mb4_bin", encoding: "utf8mb4", host: "127.0.0.1", database: "__test__", username: "root")
ActiveRecord::Migration.verbose = false
ActiveRecord::Schema.define do
  create_table :users do |t|
    t.string :xxx
  end
end

class User < ActiveRecord::Base
  before_validation do
    column = self.class.columns_hash["xxx"]
    max = column.limit
    size = xxx.size
    max = [max, size].min
    self.xxx = xxx.first(max)
  end
end

User.create!(xxx: "🍄")                            # => #<User id: 1, xxx: "🍄">
User.create!(xxx: "🍄" * 300).xxx.size             # => 255
User.create!(xxx: "🍄" * 70000).xxx.size rescue $! # => 255

文字数が大きすぎる場合はカラムの許容文字数で切っている。
普通は切らずにバリデーションするべきだけどログみたいなもんなのでOK。
コードはとくに問題なさそう。ところが──

text 型にすると失敗する

  create_table :users do |t|
-    t.string :xxx
+    t.text :xxx
  end
User.create!(xxx: "🍄")                            # => #<User id: 1, xxx: "🍄">
User.create!(xxx: "🍄" * 300).xxx.size             # => 300
User.create!(xxx: "🍄" * 70000).xxx.size rescue $! # => #<ActiveRecord::StatementInvalid: Mysql2::Error: Incorrect string value: '\xF0\x9F\x8D\x84\xF0\x9F...' for column 'xxx' at row 1>

7万文字入れたときだけ失敗しているのがわかる。原因は string 型の limit が文字数を表しているのに対して text 型の方はバイト数を表していて噛み合ってない。なので本当は limit ではなく limit / 4 を許容文字数としないといけなかった。

ActiveRecord がいくら抽象化してくれているとはいえ、このあたりは MySQL に依存しているので仕様をちゃんと理解しておかないといけなかった。一方、SQLite3 では text 型の limit は文字数を表している……というか型も limit も見てない。

対策

バイト数から文字数に変換する

  class User < ActiveRecord::Base
    before_validation do
      column = self.class.columns_hash["xxx"]
      max = column.limit
+     if column.type == :text
+       max = max / "🍄".bytesize
+     end
      size = xxx.size
      max = [max, size].min
      self.xxx = xxx.first(max)
    end
  end
User.create!(xxx: "🍄" * 70000).xxx.size rescue $! # => 16383

もっと言えば Mysql2 ならの条件を入れたい

リファクタリング

class ApplicationRecord < ActiveRecord::Base
  self.abstract_class = true

  def self.truncate(*colum_names, **options)
    before_validation(options) do
      colum_names.each do |colum_name|
        column = self.class.columns_hash[colum_name.to_s]
        if max = column.limit
          if self.class.connection.adapter_name == "Mysql2"
            if column.type == :text
              max = max / "🍄".bytesize
            end
          end
          if str = public_send(colum_name)
            if str.size > max
              str = str.first(max)
              public_send("#{colum_name}=", str)
            end
          end
        end
      end
    end
  end
end

class User < ApplicationRecord
  truncate :xxx, on: :create
end

User.create!(xxx: "🍄")                            # => #<User id: 1, xxx: "🍄">
User.create!(xxx: "🍄" * 300).xxx.size             # => 300
User.create!(xxx: "🍄" * 70000).xxx.size rescue $! # => 16383

まとめ

  • MySQL の場合 string (VARCHAR) 型を安易に text 型に変更すると動かなくなる場合がある
  • string の limit は文字数を表すが text の limit はバイト数を表しているため
  • columns_hash["xxx"].limit / "🍄".bytesize が許容文字数になる
  • 絵文字は4バイトなので MySQL の TEXT 型には 65535 / 4 = 16383 文字しか入らない
  • SQLite3 ならそんなのなんも気にしないでいい
  • SQLite3 最高

Discussion