読者です 読者をやめる 読者になる 読者になる

validates_uniqueness_ofに、enum_columnを使っていないのにenum型なattributeを検証対象としたときに起きる不具合

rails

バグとかじゃなくて、レールにのってなくて、しくじるっていうお話。

対象となるvalidates_uniqueness_ofのソースコード

使っているのがrails2.3.12とちょっと古いですがお許しを。

activerecord-2.3.12/lib/active_record/validations.rb

785       def validates_uniqueness_of(*attr_names)
#(中略)
789         validates_each(attr_names,configuration) do |record, attr_name, value|
#(中略 validationの対象になるカラムに対するquery)
804           column = finder_class.columns_hash[attr_name.to_s]
805
806           if value.nil?
807             comparison_operator = "IS ?"
808           elsif column.text?
809             comparison_operator = "#{connection.case_sensitive_equality_operator} ?"
810             value = column.limit ? value.to_s.mb_chars[0, column.limit] : value.to_s
811           else
812             comparison_operator = "= ?"
813           end
#(中略 validationのscopeとなるカラムに対するquery)
825           if scope = configuration[:scope]
826             Array(scope).map do |scope_item|
827               scope_value = record.send(scope_item)
828               condition_sql << " AND " << attribute_condition("#{record.class.quoted_table_name}.#{connection.quote_column_name(scope_item)}", scope_value)
829               condition_params << scope_value
830             end
831           end
#(中略 自分を除くquery)
833           unless record.new_record?
834             condition_sql << " AND #{record.class.quoted_table_name}.#{record.class.primary_key} <> ?"
835             condition_params << record.send(:id)
836           end
#(中略 find)
838           finder_class.with_exclusive_scope do
839             if finder_class.exists?([condition_sql, *condition_params])
840               record.errors.add(attr_name, :taken, :default => configuration[:message], :value => value)
841             end
842           end

enum_columnを使っているかどうかに依る挙動の違い

enum_columnをrequireしてる場合

>> User
=> User(id: integer, status: enum, created_at: datetime, updated_at: datetime)
>> User.columns_hash["status"]
=> #<ActiveRecord::ConnectionAdapters::MysqlColumnWithEnum:0x2ba14a5ac168 @sql_type="enum('active','inactive')", (中略) @type=:enum, @scale=nil>
>> User.columns_hash["status"].text?
=> false

enum_columnをrequireしていない場合

>> User
=> User(id: integer, status: string, created_at: datetime, updated_at: datetime)
>> User.columns_hash["status"]
=> #<ActiveRecord::ConnectionAdapters::MysqlColumn:0x2ae366a73108 @sql_type="enum('active','inactive')", (中略) @type=:string, @scale=nil>
>> User.columns_hash["status"].text?
=> true

カラムがテキストであるかどうかによるvalidationの挙動変化

ソースコードの中に書いたコメント (中略 validationの対象になるカラムに対するquery) の部分が該当。

  • columnがテキストとして判別された場合
    • case sensitiveな比較(mysqlの場合、BINARY =)になる。
    • カラムのlimitに値を削られる。
  • nilでなく、テキストでもない場合、そうでない場合

    • 単純な値比較をする
  • 上記の違いにより、enum_columnを使わずに、enum型なattributeをvalidates_uniqueness_ofの対象とした場合、limitが0にされてしまっているため、値なしとなる。

SELECT `users`.id FROM `users` WHERE (`users`.`status` = BINARY '' AND ...;

対応策

  • enum型なattributeをvalidates_uniqueness_of の検証対象から外す。=> scopeで絞る側にする。

  • なぜ、scopeにすると挙動が正しくなるか?

    • ソースコードの中に書いたコメント (中略 validationのscopeとなるカラムに対するquery) の部分が該当
    • scopeに設定されたattributeのqueryを組み立てるときは、値に対する操作が行われない。

まとめ

enumrailsがちゃんとサポートしてないんだから、不具合でるよね。レールにのってない。

といっても、test書けば挙動しないことには気づけるレベルの問題。