# ActiveRecordのコードを読んで全然わからなかった話
## Issue
Revisiting the inconsistency in enums: raises ArgumentError when making an assignment with invalid value, but returns wrong results when querying #37630
https://github.com/rails/rails/issues/37630
```ruby
class Order < ActiveRecord::Base
enum status: {
active: 0,
cancelled: 1
}
end
order = Order.new
order.status = 'canceled' # これはエラーになる!!
# This will (as expected!) raise an ArgumentError: 'canceled' is not a valid status
# However, it doesn't protect me if I screw up on the query. It's even worse:
# in this example, using MySQL, it will bug silently, because it will return
# active orders instead of cancelled ones.
Order.where(status: 'canceled') # これはエラーにならない!!
```
## やってみたこと
* Issueの中身を理解する
* rails/rails/activerecordのtestを動かしてみる
* whereの中身を読む
## Issueの中身を理解する
* .where(key: 'string')にenumに定義されてないキーを指定した時にstringが0にキャストされ意図しない振る舞いをする為、stringをキャストしていると思われる.whereメソッドを読んでみた
## activerecordのtestを動かしてみる
* 今回のIssueに対応する箇所のtestを探してきた
* enumをWhereで検索しているtestがあったらよさそう
* かつ、test caseの追加を簡単に出来そうなら嬉しい
* それっぽい箇所を発見
https://github.com/rails/rails/blob/3f1473379ce3eafc6f8a9912a7c4fb410745cac6/activerecord/test/cases/enum_test.rb#L64
```ruby
test "find via where with symbols" do
assert_equal @book, Book.where(status: :published).first
assert_not_equal @book, Book.where(status: :written).first
assert_equal @book, Book.where(status: [:published]).first
assert_not_equal @book, Book.where(status: [:written]).first
assert_not_equal @book, Book.where.not(status: :published).first
assert_equal @book, Book.where.not(status: :written).first
assert_equal books(:ddd), Book.where(read_status: :forgotten).first
end
```
* activerecord本体のtestはRakefileを叩いて動かせる
- 色んなDBを対象に動かせる
```shell
~/src/ruby/rails/activerecord(master ✔) rake test
test -- run mysql2, sqlite, and postgresql tests
test:db2 -- run tests for {"db2"=>"db2:env"}
test:jdbcderby -- run tests for {"jdbcderby"=>"jdbcderby:env"}
test:jdbch2 -- run tests for {"jdbch2"=>"jdbch2:env"}
test:jdbchsqldb -- run tests for {"jdbchsqldb"=>"jdbchsqldb:env"}
test:jdbcmysql -- run tests for {"jdbcmysql"=>"jdbcmysql:env"}
test:jdbcpostgresql -- run tests for {"jdbcpostgresql"=>"jdbcpostgresql:env"}
test:jdbcsqlite3 -- run tests for {"jdbcsqlite3"=>"jdbcsqlite3:env"}
test:mysql2 -- run tests for {"mysql2"=>"mysql2:env"}
test:oracle -- run tests for {"oracle"=>"oracle:env"}
test:postgresql -- run tests for {"postgresql"=>"postgresql:env"}
test:sqlite3 -- run tests for {"sqlite3"=>"sqlite3:env"}
test:sqlite3_mem -- run tests for {"sqlite3_mem"=>"sqlite3_mem:env"}
```
* とりあえずSQLiteのものを動かそうとした
- `test:sqlite3` => 残念ながら個人的には動かせなかった
* 単一のファイルのテストを動かしたい場合は、下記を参考にするとよいです
- minitestで書かれたActiveRecordのテストで特定のケースだけを実行する方法 by sinsokuさん
- https://sinsoku.hatenablog.com/entry/2019/08/03/182849
## whereの中身を読む
* def where を発見した
- https://github.com/rails/rails/blob/3f1473379ce3eafc6f8a9912a7c4fb410745cac6/activerecord/lib/active_record/relation/query_methods.rb#L643
```ruby
def where(opts = :chain, *rest)
if :chain == opts
WhereChain.new(spawn)
elsif opts.blank?
self
else
spawn.where!(opts, *rest)
end
end
```
* spawnって何?
- 追ってみたけどよくわからない
- spawn.where!は、多分同じクラスのwhere!を呼んでそう
- https://github.com/rails/rails/blob/3f1473379ce3eafc6f8a9912a7c4fb410745cac6/activerecord/lib/active_record/relation/spawn_methods.rb#L10
```ruby
module ActiveRecord
module SpawnMethods
# This is overridden by Associations::CollectionProxy
def spawn #:nodoc:
already_in_scope? ? klass.all : clone
end
```
- 説: `class Book` だった場合、 `Book.all : clone` みたいなやつなのかも
* where!を追ってみる
- https://github.com/rails/rails/blob/3f1473379ce3eafc6f8a9912a7c4fb410745cac6/activerecord/lib/active_record/relation/query_methods.rb#L653
- optsはHashになる想定で追っていく
- `Hoge.where(status: [:ok, :ng])` などとした時、optsは `{status: [:ok, :ng]}` になる
```ruby
def where!(opts, *rest) # :nodoc:
opts = sanitize_forbidden_attributes(opts)
references!(PredicateBuilder.references(opts)) if Hash === opts
self.where_clause += where_clause_factory.build(opts, rest)
self
end
```
* PredicateBuilderを追ってみる
-https://github.com/rails/rails/blob/592a961f299a672abbf17a9d01dea1ded5c4e35b/activerecord/lib/active_record/relation/predicate_builder.rb#L24
- このあたりからドキュメントが消える
- where に渡した引数がhashの場合`references!`を経由して key名を集めてくる
- そのkeyを集めて`where_clause_factory.build` というメソッドに渡される。
- module ActiveRecord の class PredicateBuilderへ到達
```ruby
def build_from_hash(attributes)
attributes = convert_dot_notation_to_hash(attributes)
expand_from_hash(attributes)
end
```
https://github.com/rails/rails/blob/592a961f299a672abbf17a9d01dea1ded5c4e35b/activerecord/lib/active_record/relation/predicate_builder.rb#L65
-
- 型が欲しい.....
```ruby
def expand_from_hash(attributes)
return ["1=0"] if attributes.empty?
attributes.flat_map do |key, value|
if value.is_a?(Hash) && !table.has_column?(key)
associated_predicate_builder(key).expand_from_hash(value)
elsif table.associated_with?(key)
# Find the foreign key when using queries such as:
# Post.where(author: author)
#
# For polymorphic relationships, find the foreign key and type:
# PriceEstimate.where(estimate_of: treasure)
associated_table = table.associated_table(key)
if associated_table.polymorphic_association?
case value.is_a?(Array) ? value.first : value
when Base, Relation
value = [value] unless value.is_a?(Array)
klass = PolymorphicArrayValue
end
end
klass ||= AssociationQueryValue
queries = klass.new(associated_table, value).queries.map do |query|
expand_from_hash(query).reduce(&:and)
end
queries.reduce(&:or)
elsif table.aggregated_with?(key)
mapping = table.reflect_on_aggregation(key).mapping
values = value.nil? ? [nil] : Array.wrap(value)
if mapping.length == 1 || values.empty?
column_name, aggr_attr = mapping.first
values = values.map do |object|
object.respond_to?(aggr_attr) ? object.public_send(aggr_attr) : object
end
build(table.arel_attribute(column_name), values)
else
queries = values.map do |object|
mapping.map do |field_attr, aggregate_attr|
build(table.arel_attribute(field_attr), object.try!(aggregate_attr))
end.reduce(&:and)
end
queries.reduce(&:or)
end
else
build(table.arel_attribute(key), value)
end
end
end
```