🪚
カラムの許容文字数に合わせて文字列を切る
カラムの許容文字数より大きければ切っているはずなのに本番で 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