Your browser doesn't support the features required by impress.js, so you are presented with a simplified version of this presentation.

For the best experience please use the latest Chrome or Safari browser. Firefox 10 (to be released soon) will also handle it.

Rails 5.2のActive Recordの改善

2018/02/15
PLAZMA OSS Day: TD Tech Talk 2018

Ryuta Kamizono (@kamipo)
Treasure Data, Inc.

About me

Activity

去年の8月にRailsコミッターになったのでIssues/PRをがんばって減らせるようになった

Activity

https://twitter.com/kamipo/statuses/842643547652161536

Activity

https://twitter.com/kamipo/statuses/894845890489229313

Activity

https://twitter.com/kamipo/status/959364908721688576

Rails Contributors - Edge

http://contributors.rubyonrails.org/edge/contributors

Rails 5.2.0 RC1 has released!

Rails 5.2.0 RC1: Active Storage, Redis Cache Store, HTTP/2 Early Hints, CSP, Credentials

Rails 5.2.0 RC1 has released!

リリースノートに載るような新機能はないけど5.1以前では解決されていなかった多くの問題が5.2のActive Recordでは改善されている

今日取り上げるeager_loadの問題

eager_loadとは

Active Recordで定義したassociationをJOINして親レコードをロードするときに同時に先読みする機能


  class Post < ActiveRecord::Base
    has_many :comments
  end

  class Comment < ActiveRecord::Base
    belongs_to :post
  end

  Comment.eager_load(:post).each do |comment|
    puts "#{comment.body} : commented on #{comment.post.title}"
  end

countが複雑すぎる問題

性能特性を維持するために#26972でサブクエリ内(なぜcountでサブクエリが生成されるかは後述する)のORDER BYを維持するよう修正したら(そもそもORDER BYを取り除くと結果が異なってしまうケースもある), PostgreSQL + DISTINCT + ORDER BYの制約に引っかかる問題を生み出してしまった

countが複雑すぎる問題

countが複雑すぎる問題

集合関数系メソッドの中でcountだけLIMITの扱いが特殊(countする前にLIMITが適応された結果を返す仕様になっている)


  posts = Post.order(:id).limit(3)

  posts.to_a
  # => SELECT posts.* FROM posts ORDER BY posts.id LIMIT 3

  posts.count
  # => SELECT COUNT(*) FROM (SELECT posts.* FROM posts ORDER BY posts.id LIMIT 3)

  posts.count(:published)
  # => SELECT COUNT(count_column) FROM (SELECT posts.published AS count_column FROM posts ORDER BY posts.id LIMIT 3)

countが複雑すぎる問題

joins/left_joinsincludes/eager_loadでは同じ結果になるとは限らない(eager_loadは主テーブルの件数を維持するためにCOUNT(DISTINCT id)なクエリを生成する)


  post = Post.create!(title: "post1")
  Post.create!(title: "post2")
  Post.create!(title: "post3")

  post.comments.create!([{ body: "comment1" }, { body: "comment2" }]) 

  Post.joins(:comments).count # => 2
  Post.left_joins(:comments).count # => 4
  Post.eager_load(:comments).count # => 3

countが複雑すぎる問題

joins/left_joinsincludes/eager_loadでは同じ結果になるとは限らない(eager_loadは主テーブルの件数を維持するためにCOUNT(DISTINCT id)なクエリを生成する)


  post = Post.create!(title: "post1")
  Post.create!(title: "post2")
  Post.create!(title: "post3")

  post.comments.create!([{ body: "comment1" }, { body: "comment2" }]) 

  Post.joins(:comments).map(&:id) # => [1, 1]
  Post.left_joins(:comments).map(&:id) # => [1, 1, 2, 3]
  Post.eager_load(:comments).map(&:id) # => [1, 2, 3]

countで起きていた問題

apply_join_dependency
忘れすぎ問題

apply_join_dependencyeager_loadをJOINに変換してくれるやつ。eager_loadをJOINにしないと正しいクエリにならないケースでは常にクエリ組み立て前に明示的に呼び出す必要がある。

apply_join_dependency
忘れすぎ問題

5.1以前では#to_a, #to_sql, #pluck, #exists?, 集合関数系(#count etc)だけでしか明示的に呼ばれてないのでそれ以外のJOINにしないといけないケース(#from, #where, #update_all, #delete_all, #cache_key)では正しく動かなかった

apply_join_dependency
忘れすぎ問題

5.2では#exists?#countが動くケースではほぼ#update_all, #delete_allも動くようになった


  posts = Post.joins(:comments).where("comments.hidden": true)

  if posts.exists?
    assert_equal posts.count, posts.delete_all
  end

association scope内のJOINが無視される問題

5.1以前ではeager_loadassociation scope内のJOINはすべて捨てられてしまうので、 例えば特定のタグが付いたコメントだけをeager_loadしたいようなケースで問題になる。

association scope内のJOINが無視される問題


  class Post < ActiveRecord::Base
    has_many :tagged_comments,
      -> { left_joins(:tags).where("tags.name": "Tagged") },
      class_name: "Comment", inverse_of: :post
  end

  class Comment < ActiveRecord::Base
    belongs_to :post, inverse_of: :tagged_comments
    has_many :tags
    has_many :tagged_tags, -> { where(name: "Tagged") }, class_name: "Tag"
  end

  class Tag < ActiveRecord::Base
    belongs_to :comment
  end

association scope内のJOINが無視される問題

5.2ではJOINを捨てないように修正したので以下の例は動くようになった


  class Post < ActiveRecord::Base
    has_many :tagged_comments,
      -> { left_joins(:tags).where("tags.name": "Tagged") },
      class_name: "Comment", inverse_of: :post
  end

  Post.eager_load(:tagged_comments).first

JOINを無視しないようにするとtable aliasが崩壊する問題

SQLでは同じテーブルを複数回JOINすると別の名前を付けないとどちらのテーブルのカラムを参照しているのか曖昧になってしまうので、 同じ名前がJOIN内に出現しないようにtable aliasを振っていく必要がある

JOINを無視しないようにするとtable aliasが崩壊する問題

JOINをこれまで捨てていたのでActive Recordにはassociation scope内のJOINにまでalias trackingが考慮されていない問題があった。 以下の例はtagsテーブルが2回JOINされてエラーになる。


  class Post < ActiveRecord::Base
    has_many :tagged_comments,
      -> { left_joins(:tags).where("tags.name": "Tagged") },
      class_name: "Comment", inverse_of: :post
  end

  Post.eager_load(tagged_comments: :tags).first

JOINを無視しないようにするとtable aliasが崩壊する問題

alias trackingを直したので直接テーブル名を指定しないようにしたら動いて欲しかったけど現時点では以下の例は動かない…


  class Post < ActiveRecord::Base
    has_many :tagged_comments,
      -> { left_joins(:tagged_tags) },
      class_name: "Comment", inverse_of: :post
  end

  Post.eager_load(tagged_comments: :tags).first

JOINを無視しないようにするとtable aliasが崩壊する問題

少し効率は悪くなるけどworkaroundとして以下の例なら動作する


  class Post < ActiveRecord::Base
    has_many :comments
    has_many :tagged_tags, through: :comments, source: :tagged_tags
    has_many :tagged_comments, through: :tagged_tags, source: :comment
  end

  Post.eager_load(tagged_comments: :tags).first

その他の改善

今日取り上げたeager_loadの問題

まとめ

Use a spacebar or arrow keys to navigate