# 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 ```