Skip to content

Instantly share code, notes, and snippets.

@yano3nora
Last active August 24, 2023 01:46
Show Gist options
  • Save yano3nora/d96265643d0acc1a262d2e8aac029ab5 to your computer and use it in GitHub Desktop.
Save yano3nora/d96265643d0acc1a262d2e8aac029ab5 to your computer and use it in GitHub Desktop.
[rails: Model / Active Record] Active Record as model of Ruby on Rails. #ruby #rails

https://qiita.com/k0kubun/items/80c5a5494f53bb88dc58 ORM の join については下の方に書いてある。5,000 回くらい調べてるので先頭にまとめを書いておく

  • joins
    • INNER JOIN
    • ActiveRecord キャッシュしない
  • left_joins
    • LEFT OUTER JOIN
    • ActiveRecord キャッシュしない
    • Rails v5 から
  • preload
    • 指定 Association を _id IN(1, 2, 3, ...) 形式で別クエリ発行
    • ActiveRecord キャッシュする
    • Association 先で where() すると例外発生
  • eager_load
    • 指定 Association を LEFT OUTER JOIN で連結
    • ActiveRecord キャッシュする
    • Association 先の絞り込み可能、クエリが少ない分 preload より早いケースも
  • includes
    • ↓ 条件のとき eager_load, そうでないとき preload する
    • 指定 Association への絞り込みをする
    • 指定 Association への joins or references を呼ぶ
    • 何らかの Association を eager_load する

TRANSACTION

Active Record Transactions
【Rails】絶対に抑えたいTransactionのポイント rubyの例外についてまとめてみた

def saveFinances(balance, account)
  begin
    ActiveRecord::Base.transaction do
      # しくじったら例外 + コールバック実行
      balance.save!
      account.save!
      # raise ActiveRecord::Rollback  # 手動で Rollback を実行したりもできる
    end
  rescue ActiveRecord::RecordInvalid => e
    # 例外処理
  rescue => e
    # 例外処理
  end
end

Nested transaction

一応ネストも可能だが、ネストされた先で ::Rollback を叩けなくなったり、分岐処理が複雑になるため可能な限り避ける。

モデルA.transaction do
  #モデルAでやりたい処理
  モデルB.transaction do
    # モデルBでやりたい処理
    モデルC.transaction do
      # モデルCでやりたい処理
    end
  end
end

Use with Parallel & connection_pool.with_connection

ActiveRecordのコネクションプールの理解を深める
並列マルチプロセスブロック内でActiveRecordトランザクションを使用する方法は?

Parallel を利用してマルチ ( 並列 ) プロセスで、かつ各プロセスで ActiveRecord::Base.connection_pool.with_connection で DB 接続を使いまわして、かつトランザクション張る ... という場合の書き方。

# in_threads で並列プロセスのワーカータイプを「プロセス→スレッド」へ明示的に変更
Parallel.each(new_records, in_threads: 8) do |record|
  ActiveRecord::Base.connection_pool.with_connection do
    ActiveRecord::Base.transaction do
      edit_record(record)
      record.save!
    end
  end
end

ATTRIBUTES

ユーザークラス - railstutorial.jp
【attr_accessor】って?
ActiveModel::Attributes が最高すぎるんだよな。
Rails Virtual Attributes
Railsで学ぶ責務のお話

class User
  # Ruby の attr_accessor で @name, @email へのアクセサを自動登録
  attr_accessor :name, :email

  # インスタンス化された際のコンストラクタで @name, @email を用意
  def initialize(*)  # 可変長引数 * をとり以下 super へ投げる
    super  # オーバライドすることになるので super で親処理を呼んでおくこと
    @name  = self.name   # super で self が組みあがってる
    @email = self.email
  end

  # MVC 各所で利用する仮想プロパティみたいなものをつくる
  # `puts "#{@user.name} <#{@user.email}>"` という
  # 出力上のロジックをコードのあちこちにまき散らさなくて済む
  def formatted_email
    "#{@name} <#{@email}>"
    # ここまで書いてなんだけど attr_accessor と initialize 使わなくても
    # ここで "#{self.name} #{self.email}" って書いてもとりあえずは動きます
  end
end

Callback

アプリケーションの中で大きなビジネスロジックに膨れそうなものは callback で作り込むとすぐ死ぬので注意。バリデーションは極力 validates でやって、本当に普遍的な callback だけにとどめたほうがいい。

callback を大量に仕込む代替手段としては、多分 observer パターンで event 発行したときに service class を do するみたいにした方が良さそう。

バッチ処理や rspec なんかで callback skip したいときは skip_callback とかもある。但しスレッドセーフでないため、ご利用は計画的に。

# https://api.rubyonrails.org/classes/ActiveSupport/Callbacks/ClassMethods.html#method-i-skip_callback
User.skip_callback(:save, :after, :fuga)

user = User.find(1)
user.name = 'john'
user.save  # after_save の fuga メソッドスキップされる

あとは attr_accessor つかってフラグっぽく使う手段も。

# Model
attr_accessor :skip_my_callback
after_save :my_callback, unless: :skip_my_callback

#controller
Model.create skip_my_callback: true

enum

Rails enumについてまとめておく
ActiveRecord::Enum
Rails5 から enum 使う時は_prefix(接頭辞)_suffix(接尾辞)を使おう

よくある「 xx タイプ」みたいな固定値を割り振って「ステータス」や「タイプ」を管理するアトリビュートは enum メソッドで「ステータス - 未読:1、既読2」みたいな感じで定義するのが Rails 4 から可能になっている。

Rails 4 時点では where 句でシンボル使えないので注意。

class Article < ActiveRecord::Base
  # enumの定義(キーと数字のハッシュを渡す。数字がDBカラムに設定される)
  enum status: { drafted: 0, published: 1 }
end

# マイグレーションはこんなかんじ
class CreateArticles < ActiveRecord::Migration
  def change
    create_table :articles do |t|
      t.integer :status, default: 0, null: false, limit: 1

      t.timestamps null: false
    end

    add_index :articles, :status
  end
end

# 勝手に確認メソッドが override & 追加される
article = Article.first
article.status      # 'drafted' とかが返る
article.drafted?    # status により bool を返す
article.published?  # status により bool を返す
article.published!  # status を published へ変更

# enum 一覧を取得
Article.statuses
=> { drafted: 0, published: 1 }

Rails 4 以前に開発されたシステムでは enum を利用せず、以下のように Module で切り出してクラス定数化しちゃうのが一般的だったみたい?

class Message < ActiveRecord::Base
  module ReadStatus
    UN_READ     = 0
    READ        = 1
    LABEL       = { UN_READ => '未読', READ => '既読' }
  end
  READ_STATUSES = [ReadStatus::UN_READ, ReadStatus::READ]
end

Override Default Accessor

ActiveRecord::Base デフォルトのアクセサーの上書き - api.rubyonrails.org

class Song < ActiveRecord::Base
  # Uses an integer of seconds to hold the length of the song

  def length=(minutes)
    super(minutes.to_i * 60)
  end

  def length
    super / 60
  end
end

ActiveModel::Serialization

ActiveModel::Serialization
ActiveModel::Serializers::JSON ActiveModel ::シリアライザで条件付きでサイドローディング

def attributes

Active Model の基礎 > シリアライズ

ActiveModel は to_json や to_xml のときデフォルトで「全 attributes を吐く」ため、秘匿情報を不用意にシリアライズしたくないときに attributes のオーバライドが必要。

class User < ActiveRecord
  def attributes
    # except は ActiveSupport メソッドなので注意
    # あとここはシンボル使えません
    super.except('encrypted_token')
  end
end

# User.first.to_json や to_xml に encrypted_token がのらなくなる

to_json

# https://qiita.com/eggc/items/29a3c9a41d77227fb10a

# 基本
Book.first.to_json
Book.all.to_json

# 特定のカラムを出力しないようにする
Book.first.to_json(except: [:id, :body])

# 特定のカラムだけを出力する
Book.first.to_json(only: [:title])

# メソッドの結果をカラムのようにして出力する
Book.first.to_json(methods: [:summary])
Book.first.to_json(methods: [:summary], only: [:summary]) # summary 以外出力しない

# 関連モデルを出力する
Book.first.to_json(include: [:author, :publisher])
Book.first.to_json(include: [:author, {publisher: {only: :name}}])
Book.first.to_json(include: {author: {include: :group}})

# 詳細に指定
Book.all.to_json(
  only: [], # Book のカラムはすべて不要
  include: {
    publisher: {} # Publisher のオプションは無し(デフォルトで出力)
    author: {
      include: {
        group: { # Group は group_name メソッドの結果だけ出力
          methods: :group_name
          only: :group_name
        }
      }
    }
  }
)

ASSOCIATION

Active Record の関連付け (アソシエーション)
ActiveRecord::Associations::ClassMethods

# 等価?
john = User.new name: 'John', email: 'john@example.com'
john.articles.build({ title: 'My Article 1', body: '....' }, { title: 'My Article 2', body: '....' })
john.save

johns_article_1 = Article.new title, 'My Article 1', body: '....'
johns_article_1.build_user name: 'John', email: 'john@example.com'
johns_article_1.save
johns_article_2 = Article.new user: johns_article_1.user, title, 'My Article 2', body: '....'
johns_article_2.save

john = User.new name: 'John', email: 'john@example.com'
johns_article_1 = Article.new user: john, title: 'My Article 1', body: '....'
johns_article_1.save 
johns_article_2 = Article.new user: john, title: 'My Article 2', body: '....'
johns_article_2.save

:through

JOIN しまくって遠めの関連をたどりたいときは :through で間に入るモデルを中間テーブルとして扱わせるのが楽。

class User < ActiveRecord::Base
  has_many :tweets
  has_many :favorites, through: :tweets
end

user = User.first
user.favorites  # ユーザのツイートにぶら下がるお気にいりカモン!

# ↑ のようにしないで、近い relation しか定義してないと辿るの結構面倒
# 例えば user.tweets.favorites とは書けない ( tweet.favorites なので
# 複数ある tweets を飛ばして favorites にいけるのが楽なポイント

因みに Scoped Relation でも :source オプション使えばイケる。下手に導出用のモデルメソッド生やしたり Query Object 作ったりするより、関連で表しきったほうが後から見直したときもわかりやすい気がする。

class User < ActiveRecord::Base
  has_many :tweets
  has_many :tweet_images, through: :tweets
  #
  # お気に入りされた画像つきツイートの Scoped Relation
  #
  has_many :favorited_has_image_tweets, lambda {
    joins(:tweet_images, :favorites)  # INNER JOIN で持ってないやつ落とす
    order(created_at: :desc)
  }, class_name: 'Tweet'
  #
  # ↑ を through した仮想 relation 、source で実際のモデル指定
  #
  has_many :favorited_images, through: :favorited_has_image_tweets, source: :tweet_image
end

user = User.first
user.favorited_images  # お気に入りされた画像たち

inverse_of

https://shoken.hatenablog.com/entry/2015/07/14/095211
inverse_ofを指定したリレーションのある2つのモデルでは、双方から同一のインスタンスを参照できるようになる。両者ともメモリ上で同一のインスタンスとして扱われる。逆に、inverse_ofの設定が無いと同一として扱われず、一方からの変更がもう一方から参照しても変更されていない。

なんど調べてもわからんが、ざっくり「逆参照を明示しておいて、あきらかに同じレコード見てるものは SQL 発行 → ActiveRecord 生成を省略してメモリ上の同一インスタンスの参照を渡して節約しよう」ってことみたいです。

  • rails 4.1 からは自動決定されてる
  • :through や :foreign_key など一部オプションとの併用時には ↑ の自動決定が働かないらしい
  • rubocop さんにそれで怒られる
  • とはいえ :class_name で指定していて、元の class_name 側の自動決定がちゃんと動作してることもある
  • とりあえず逆の設定をしておけばいいみたい
class Member < ActiveRecord::Base
  has_many :comments
end

class Comment < ActiveRecord::Base
  belongs_to :member, inverse_of: :comments
end

comment = Comment.where(member_id: 1).last
member  = comment.member

same_comment = member.comments.last
same_member  = same_comment.member
#
# ↑ この same_member 探索時点で SQL 発行されない
# inverse_of がないと SQL 発行され別インスタンスとして new される

member.email       #=> 'test@example.com'
same_member.email  #=> 'test@example.com'

# メモリ上で同一インスタンスなので attribute の変更も同期される
# inverse_of がないと別インスタンスなので同期されない
#
member.email = 'modified@example.com'
member.email       #=> 'modified@example.com'
same_member.email  #=> 'modified@example.com'

has_one の使い所

「 1 つしか持たせたくない」を強制するなら unique 制約がいるかなーやっぱ、という印象。「要件的には 1 つ」なんだけど「システム側では一時的に複数持つ瞬間がある」なら has_many にしておいて has_one を scoped で定義した方が柔軟性がありそう。

class User < ActiveRecord::Base
  has_one :password_reset, dependent: :destroy
end

class PasswordReset < ActiveRecord::Base
  belongs_to :user

  before_save { unique_token(self.class, :token) }
  after_save { UserMailer.password_reset(self.user).deliver! }
end

user.build_password_reset.save  # delete が走ってから insert になる
user.build_password_reset.save  # 2 叩いても ↑ のおかげで has_one が保たれる
class User < ActiveRecord::Base
  has_many :posts
  has_one :latest_post, lambda {
    order(created_at: :desc)
  }, class_name: 'Post', inverse_of: :user
end

class Post < ActiveRecord::Base
  belongs_to :user
end

user = User.last

user.posts

user.latest_post

Scope Block Association / 仮想アソシエーション

ActiveRecord::Associations > Customizing the query
Scopes
:class_name
How to Preload Rails Scopes

  • limit は使うな、普通の scope で絞れっていう思想みたい
class User < ApplicationRecord
  has_many :articles, dependent: :destroy
  has_many :public_articles, -> {
    # Scope block of association.
    where is_public: true
  }, class_name: 'Article'
end
# users_controller
def index
  @users = User.includes(:public_articles).all
end

with args

class User < ActiveRecord::Base
  has_many :birthday_events, ->(user) { where(starts_on: user.birthday) }, class_name: 'Event'
end

# 多分こう
User.birthday_events(current_user)

# このようなインスタンス依存スコープのプリロード ( joins など ) はサポート外
User.includes(:birthday_events)  # そもそも引数渡せないし仮に渡せても動きません

accepts_nested_attributes_for / 関連モデルのネスト

class User < ApplicationRecord
  # こんなアソシエーションのとき ...
  has_one  :role
  has_many :articles

  # 以下設定でビューの form_helper での一括登録/編集が可能になる
  accepts_nested_attributes_for :role
  accepts_nested_attributes_for :articles
end

:dependent / 所属エンティティ削除時の振る舞い

:dependent
restrict_with_exception と restrict_with_error の違いをサンプルコードで確認する

# 指定 dependent: :destroy 

:destroy                  # 通常の削除 ( デフォルト )
:delete                   # コールバックをスキップして DELETE ( has_one 用 )
:delete_all               # コールバックをスキップして DELETE ( has_many 用 )
:nulltify                 # コールバックをスキップして NULL
:restrict_with_exception  # 例外が発生
:restrict_with_error      # エラーが付与される

has_and_belongs_to_many の参照先を殺す

habtm relationship does not support :dependent option

Article が has_and_belongs_to_many :likes なとき、Article が消えたら User たちが記事につけた各 Like が消えて欲しい ... みたいなケース。

Article が has_and_belongs_to_many :likesdependent: :destroy を付けた場合、中間テーブル articles_likes のレコードは Article の削除時に消えるが、各 User の Like は消えないような挙動になる。このとき User の Like は不要になるはずなので、以下のようにして削除にコールバックを仕込んで殺す。

class Article < ApplicationRecord
  has_and_belongs_to_many :likes, dependent: :destroy

  before_destroy do
    # Article 削除時に Like マスタから多対多で当該 Article を参照するはずの Like を殺す
    likes.each { |like| like.destroy }
  end
end

:optional / 所属エンティティへの依存・非依存

:optional Rails5からbelongs_to関連はデフォルトでrequired: trueになる

class Address < ApplicationRecord
  belongs_to :customer, optional: true  # アドレス帳は顧客に非依存である
end

:touch 関連エンティティ更新時の振る舞い

ActiveRecord::Persistence#touch()
touch, touch, updated_at に touch

class Anime < ActiveRecord::Base
  has_many :characters
end

class Character < ActiveRecord::Base
  belongs_to :anime, touch: true
end

@character.save
# このタイミングで @character.anime.touch と同じ処理が走り
# 親モデルの updated_at が更新される
# published_at みたいな独自カラムがある場合はマニュアルを見てね

ActiveRecordのtouchをno_touchingで一時的に無効にする(翻訳)

touch を一時的に無効にしたい ( どうせバッチとか通知とか ) ときは ↓ のように no_touching ブロックで囲えばおk。

User.no_touching do
  user.photos.find_each do |photo|
    # userは touch されない
    photo.update!(some_attributes)
  end
end

VALIDATION

Active Record バリデーション

# 基本の流れ
class Person < ApplicationRecord
  validates :name, presence: true
  #
  # 独自バリデーションを組みたい場合は validate を使ってメソッド名を指定
  # validate :custom_validation_method, on: :create
  #
  # private
  #
  # def custom_validation_method
  #   if うにょなら errors.add するなり好きにして 
  # end
end
 
p = Person.new     # => #<Person id: nil, name: nil>
p.errors.messages  # => {}
p.valid?           # => false
p.errors.messages  # => {name:['空欄にはできません']}
 
p = Person.create  # => #<Person id: nil, name: nil>
p.errors.messages  # => {name:['空欄にはできません']}
p.save             # => false
p.save!            # => Exception ActiveRecord::RecordInvalid: Validation failed: 空欄にはできません

Person.create!     # => ActiveRecord::RecordInvalid: Validation failed: 空欄にはできません

# Errors
Person.new.errors.any?           # => false
Person.new.errors[:name].any?    # => false
Person.create.errors[:name].any? # => true
person.valid?                    # => false & バリデーショントリガ
person.errors.details[:name]     # => [{error: :blank}]

# バリデーショントリガー Model メソッド
valid? / invalid?
create / create!
save   / save!
update / update!

# バリデーションをスキップする Model メソッド
save(validate: false)
toggle!
touch
decrement! / decrement_counter
increment! / increment_counter
update_all / update_attribute / update_column / update_columns / update_counters

Validation Helpers

Railsバリデーションまとめ

# 空でない/空である
validates :title, presence: true
validates :title, absence:  true

# ユニーク/一致
validates :name,  uniqueness:   true
validates :title, uniqueness: { scope: :user_id }  # 他カラムとの掛け合わせで
validates :email, confirmation: true  # 自動で %{attribute}_confirmation と比較

# 列挙に含まれる/含まれない
validates :is_published, inclusion : { in: [true, false] }
validates :subdomain,    exclusion:  { in: %w(www us ca jp) }
validates :size,         inclusion: { 
  in:      %w(small medium large),           # 配列オブジェクトへ
  message: "%{value}は有効な値ではありません"  # message: では ${value} %{attribute} %{model} などが取れる
}

# チェックボックス
validates :terms_of_service, acceptance: { message: '規約に同意してね' }

# 長さ / length
validates :title,    length: { minimum: 1 }       # 「1文字以上」
validates :title,    length: { maximum: 75 }      # 「75文字以下」
validates :title,    length: { in: 1..75 }        # 「1文字以上75文字以下」
validates :password, length: { is: 8 }            # 「8文字のみ」

# has_many や has_and_belongs_to_many の個数制限
validates :tags, length: {
  maximum: ->(object) do
    object.plan.tag_quantity
  end
}


# フォーマット / 数値
validates :age, numericality: true
validates :age, numericality: { only_integer: true }
validates :age, numericality: { greater_than: true }
validates :age, numericality: { greater_than_or_equal_to: true }
validates :age, numericality: { equal_to: true }
validates :age, numericality: { less_than: true }
validates :age, numericality: { less_than_or_equal_to: true }
validates :age, numericality: { odd: true }
validates :age, numericality: { even: true }

# フォーマット / 正規表現
require 'uri'
validates :email, presence: true, uniqueness: true, format: { with: /\A#{URI::MailTo::EMAIL_REGEXP}\z/ }
validates :url,   presence: true,                   format: { with: /\A#{URI::regexp(%w(http https))}\z/ }

# 条件付き / :on
validate :active_customer, on: :create

# 条件付き / :allow_nil, :allow_blank
validate :email, confirmation: true, :allow_blank  # nil の場合にスキップ
validate :email, confirmation: true, :allow_blank  # blank? => true の場合にスキップ

# 条件付き / :if ~ ならバリデート実行, :unless ~ でないならバリデート実行
validates :password, length: { minimum: 12 }, if: is_admin?

# 真偽値を返すプロシージャ ( クロージャ ) で条件を定義
validates :password, confirmation: true,
  unless: Proc.new { |user| user.password.blank? }

# Lambda 式で定義
validates :password, confirmation: true, unless: -> user { user.password.blank? }

# シンボルで定義
validates :card_number, presence: true, if: :paid_with_card? 
def paid_with_card?
  payment_type == 'card'
end

ACTIVE RECORD

Active Record の基礎
Active Record クエリインターフェイス
ActiveRecord::Associations::CollectionProxy
Ruby on RailsのCRUDを学ぶ。Update編。updateコマンドとsaveコマンド。バリーデータの有無

Rails の ORM は アクティブレコードパターン を採用している。

最もシンプルなレコード取得 find() / find_by!() は失敗時に ActiveRecord::RecordNotFound 例外を投げるため、コントローラで「存在している & 自身の所有 ( 権限がある ) を確認する場合は積極的に利用して例外で 404 に落とすようにするのがきれい。

# SELECT 
Book.all             # Books || Relation  - 一括取得でメモリ即死するやつ
Book.first           # Book  || nil       - second third や take / last も同等
Book.find(7)         # Book  || Exception - ID 付与なのでなければ例外発生
Book.where(id: 7)    # Book  || Relation  - クエリ続行 .nonzero? とかで存在確認
Book.find_by id: 7   # Book  || nil       - マッチした最初のレコードを返却
Book.find_by! id: 7  # Book  || Exception - なければ例外!パターン
Book.exists?(id: 7)  # true  || false     - 固めの存在確認
# INSERT
## new
user = User.new name: 'John', email: 'john.do@example.jp'
user.new_record?  # true
user.save         # true / DB へ新規作成

## create
user = User.create({name: 'Jane', email: 'jane.do@example.jp'})
user.new_record?  # false / create で生成と DB 保存実行済み
user.persisted?   # true / DB に存在するかどうか


# UPDATE
## update
user.update({ email: 'new.email@example.jp' })

## save
user.attributes = { name: 'Chloe', email: 'chloe.lucifer@example.jp' }  
user.email = 'chloe.does.lucifer@example.jp'  # 更新は ← でも ↑ でも OK
user.save

## save / save! / create! / errors
user = User.new
user.attributes = { email: 'john.do@example.jp' }
user.save        # false / 失敗したため DB 変更なし
user.errors.full_messages  # ["Email is already exists."]
user.save!       # ← も ↓ も ActiveRecord::RecordInvalid 例外
User.create! name: 'John', email: 'john.do@example.jp'

## reload
user.reload  # DB 保存されてない dirty なカラム変更を DB 状態へ戻す

## update_all / update_attribute
user.update_attribute(is_active: false)  # バリデーションをスキップして更新
User.where(is_active: true).update_all(is_active: false)  # こちらもバリデーション省略につき注意


# DELETE
## destroy / destroy_all - ActiveRecord を介した ( dependent 削除コールバック実行 ) 削除

# class User < ApplicationRecord
#   has_many :posts, dependent: :destroy
# end
User.find(1).destroy            # user#1 と配下の依存モデル全削除
User.find(2).posts.destroy_all  # user#2 配下の全 posts とその配下依存モデル全削除

## delete / delete_all - ActiveRecord を介さない SQL による DELETE
User.find(3).delete            # user#3 DELETE ( 依存モデルのふるまいは外部キー制約による )
User.find(4).posts.delete_all  # user#4 配下の全 posts DELETE ( 依存モデルのふるまいは外部キー制約による )

rewhere, reorder + where_values_hash

rewhere

既にセットされた where / order 条件は rewhere / reorder で上書きできる。( このくらい中でうまいことやって欲しい気もするけど

Article.where(trashed: true).rewhere(trashed: false)

# SELECT * FROM articles WHERE `trashed` = 0

where_values_hash でセットされている where 条件が hash で取得できるので「 IN 句に追加」とかもできる。

statuses   = [99]
where_hash = @relation.where_values_hash

if where_hash.key?('status')
  statuses.push(*where_hash['status']) 
end

@relation.rewhere(status: statuses)

Explain

EXPLAIN を実行する - railsguides.jp
PostgreSQL: Using EXPLAIN
MySQL: EXPLAIN Output Format
大量データ検索のためのデータベース対策あれこれ

行数が単一テーブルだけで 100 万行あるような中/大規模な DB 相手にテキトーな Query を叩くと、いくらインフラをスケールアップさせていても RDBMS は単純な SELECT 文であっというまにハングしてしまう。( 基本的には index を適切に貼っていれば大丈夫だが ... )

ActiveRecord は .explain メソッドで各 RDBMS の EXPLAIN - クエリー実行プラン を整形出力することができる。特に type: ALL なフルテーブルスキャンを行うようなクエリは大規模 DB では必ず死ぬ、ORM によって組まれた SQL が「実行結果が期待通りか」だけでなく「パフォーマンス上問題ないか」を確認するために積極的に利用すること。

User.where(id: 1).joins(:articles).explain

EXPLAIN for: SELECT `users`.* FROM `users` INNER JOIN `articles` ON `articles`.`user_id` = `users`.`id` WHERE `users`.`id` = 1
+----+-------------+----------+-------+---------------+
| id | select_type | table    | type  | possible_keys |
+----+-------------+----------+-------+---------------+
|  1 | SIMPLE      | users    | const | PRIMARY       |
|  1 | SIMPLE      | articles | ALL   | NULL          |
+----+-------------+----------+-------+---------------+
+---------+---------+-------+------+-------------+
| key     | key_len | ref   | rows | Extra       |
+---------+---------+-------+------+-------------+
| PRIMARY | 4       | const |    1 |             |
| NULL    | NULL    | NULL  |    1 | Using where |
+---------+---------+-------+------+-------------+

Relation

ActiveRecord::Relation
ActiveRecord::Relationとは一体なんなのか
覚えておくと幸せになれるActiveRecord::Relationメソッド6選
【Rails5】Active Record のクエリ

CakePHP の Cake\ORM\Query オブジェクトみたいなもん。

# basic
User.where(name: 'taro')
User.where(name: ['taro', 'ziro'])

# and
User.where(name: 'taro').where(age: 10)
User.where(name: 'taro', age: 10)

# not
User.where.not(name: 'taro')

# or
User.where(gender: :male).or(User.where(gender: :female))
User.where(name: 'taro', gender: :male)
  .or(User.where(age: nil))  # age: nil な female も含まれる

# placeholder
User.where('age > :age and email != :email', age: 18, email: nil)

# like
User.where('name like :name', name: "%#{name}%")

# order
User.order(:id)
User.order(id: :asc)
User.order(name: :asc, id: :asc)

# order by field - http://mikamisan.hatenablog.com/entry/2017/03/22/230615
User.where(id: ids).order(['field(id, ?)', ids])

# select
User.select(:name)
User.select(:id)
#
# AS で別名つけて through 中間モデルの attribute を has_many 先にくっつける
# https://blog.kotamiyake.me/tech/join-attributes-to-relation-table/
#
# 例: User は複数の Account を through した Role 違いで持てるとして
# User.first.accounts 時に through している Role の attribute が欲しい
#
user = User.first
user.accounts.select('accounts.*, roles.name AS role_name').first.role_name # admin 的な


# limit / offset
User.limit(5)
User.limit(5).offset(10)

# count
User.where(condition).count   # 都度 COUNT SQL 発行
User.where(condition).size    # COUNT SQL 発行 → キャッシュあればそちらを利用
User.where(condition).length  # 取得結果の個数返却 ( to_a.length と同じ?

# count + distinct
#
# 通常 User.where(condition).count で OK だが
# リレーションを無視した INNER JOIN とかしたときに
# JOIN したカラムで DISTINCT するようなときは指定してやる
#
# https://qiita.com/nysalor/items/ec0c0a45c78170bc4d73
#
users = User
  .joins('INNER JOIN not_related_articles ON users.id = not_related_articles.user_id ')
  .distinct('not_related_articles.id')

users.count('DISTINCT not_related_articles.id')

# distinct
#
# has_many を inner join するときなどは条件によるが基本重複するので忘れずに
# 但し、大きなテーブルにかけると distinct は結構重たいので注意
# FYI: https://www.greptips.com/posts/386/
#
User.joins(:articles).where(articles: { published: true }).distinct


# average / minimum / maximum / sum
User.average(:age)  # 22
User.maximum(:age)  # 61
User.minimum(:age)  # 13
User.sum(:point)    # 13

# group / having
User.group(:rank).count  # ユーザランク毎のカウント
User.group(:rank).having(rank: ['bronze', 'silver', 'gold']).count  # 条件付き

# ids
User.ids  # [1, 2, 3 ... ]

# pluck
User.pluck(:id, :name)  # [[1, "email.one@example.jp"], [2, "email.two@example.jp"]]
#
# 既にメモリ上に展開された結果セットでないなら
# User.all.map(&:id) とかするより pluck(:id) のが早い
# あと↓みたいな使い方もできる
#
# Ref: Rails ActiveRecordでランダムにレコードを1件取得する
# https://easyramble.com/get-record-randomly-with-active-record.html
#
# user.articles.sample(3) とかすると全取得になって重いので
# Article.find(user.articles.pluck(:id).sample(3)) とかにできる

# none
User.count  # 100
User.none   # 本当は存在するがダミーとして空の Relation が返る

# find_or_create_by / あれば取得、なければ作成 find_or_create_by! で新規作成失敗時に例外
# find_or_initialize_by もあり、こちらはなければ初期化 ( 保存はまだしない )
Tag.find_or_create_by(:name => 'ruby') do |tag|
  tag.type = 'programming'
end

# scoping / ブロックへ Relation を引き渡せる
Comment.where(:post_id => 1).scoping do
  Comment.first  # SELECT * FROM comments WHERE post_id = 1
end

# to_sql / explain 
User.where(1).to_sql   # 実行 SQL 文が返る
User.where(1).explain  # 実行 SQL がコンソール用に成形されて出力される

find_each / find_in_batches

Rails のループ処理(each, find_each, find_in_batches)

find_each も find_in_batches も limit や order は無視される から気をつけてね。

# each / 一括取得 → 一括メモリ展開ループ ( 必ずクエリに LIMIT つけること )
User.all.each { |user| puts user.email }

# find_each / 分割取得 ( デフォルト 1000 件 ) → レコード毎処理
User.where(age: 10..20).find_each { |user| puts user.email }  # バッチなので処理順の指定はできない
User.where(age: 10..20).find_each(batch_size: 100) { |user| puts user.email }  # 分割サイズ変更

# find_in_batches / 分割取得 ( デフォルト 1000 件 ) → レコードを配列展開し配列毎処理
# レコード毎処理じゃない以外は find_each と同等
# find_each と同様に batch_size 指定や start / finish で開始終了の主キー指定が可能
User.all.find_in_batches(start: 100, finish: 300) { |user| puts user.email }

scope

Rails Modelのscopeとlambda

CakePHP のカスタム Finder のように任意の Relation にシンボルを付けて固めておける。

# app/models/article.rb

# デフォルトスコープ指定 ( スコープ指定していない全検索クエリに適用される )
# あたりまえだが全クエリに暗黙で追加されるので乱用すると即死する、ご利用は計画的に。
default_scope :published, -> { where(published: true) }
  
# カスタムスコープ定義
scope :published_after, -> (time) { where('published_date >', time) }
# app/controllers/articles_controller.rb

def index
  @articles = Article.published_after(Time.now())
end

merge

merge

前段 Relation からクエリ条件を引き継ぎ、条件をさらに結合させることができる。内部的には SQL が () で囲われるっぽい。

# merge で joins 先のモデルの条件づけを行う
User.joins(:roles).merge(Role.where(name: :manager))

# or + join / merge はいったんクエリを保存してから
relation = User.joins(:roles)
relation.merge(Role.where(name: :manager))
  .or(relation.where(gender: :male)  # マネージャロール持ちユーザまたは男性ユーザ

joins

joins
ActiveRecordのjoinsとpreloadとincludesとeager_loadの違い
Rails における内部結合、外部結合まとめ
関連するモデルの条件で検索したい

joins は純粋 INNER JOIN 用のメソッドで、関連を親テーブルにくっつける。配下モデルと必ずセットで取得/検索を行いたいときのやつ。ちなみに INNER JOIN なので関連が存在しなければ親レコードは NULL るし配下モデルのアソシエーション構成によっては .distinct しないと被りが出る。

また、クエリに JOIN をせず関連モデルについて SQL を複数回発行して内部キャッシュし、結果セットに「後から関連モデルデータをくっつける」といったパフォーマンスを考慮した preload メソッドというのもある。

# 2013 年映画に出演したアクター全て
Actress.joins(:movies).where(movies: { year: 2013 })
Actress.joins(:movies).merge(Movie.year_2013)  # merge を利用してこうも書ける

# コメントとタグを保持する記事を保持するユーザ全て
User.joins(articles: [:comments, :tags]).distinct

# 複数のテーブルからのデータをフィルタして取得する
Person
  .select('people.id, people.name, comments.text')
  .joins(:comments)
  .where('comments.created_at > ?', 1.week.ago)

# 複数のテーブルから特定のデータを取得する
Person
  .select('people.id, people.name, companies.name')
  .joins(:company)
  .find_by('people.name' => 'John') # 名を指定

eager_load + joins

railsでのeager_loadの結合条件の追加はできますか?

joins 以降の配下モデルへの where は CakePHP の matching のように配下モデルベースで親モデルを条件づけ する。INNER JOIN する配下モデルに対して条件付けし、条件一致する配下モデルを持たない親モデルも取得する ... つまり CakePHP3 の Cake\ORM\Query::contain() + 配下モデルへの where() 条件クロージャのような 特定条件配下モデルのみ INNER JOIN したい場合は eager_load による事前の LEFT OUTER JOIN 先読みキャッシュ + joins への SQL による AND 条件追加を利用 する。

あんまり自信ないケド、以下では eager_load で関連配下モデルを LEFT OUTER JOIN で先読みキャッシュして、後続の joins の挙動が INNER JOIN から LEFT OUTER JOIN に変更されている。ここで SQL を引数にとれる joins に対して AND models.attribute = ? のように条件を渡すことで、LEFT OUTER JOIN の ON 条件が加わり、結果的に eager_load キャッシュから取得する配下モデルレコードが条件で絞りこまれて、親レコード側は配下レコードがないときも NULL にならない ... ということだと思う。多分。

ここまで書いておいてナンだけど、部分的な実装でないなら後述する Scope Block Association + includes で対処したほうがスマート。但し current_user が所有する配下モデルだけ ... のように条件が動的なものはコレで対処せざるを得ない。

# Article は様々な User から Like されている
#
# :users    has_many :likes
# :articles has_many :likes
# :likes    belongs_to :user, :article
#
# Like している / いないに関わらず Articles を全件取得したい
# 加えて current_user がつけた Like がある Article に関して
# 当該 Like のみを Article に INNER JOIN した状態で取得したい
# * has_many なので current_user の Like 1 つ分を likes 配列内に保持
# * Like していない Article の likes は条件なし eager_load 時同様に空配列
#
Article
  .eager_load(:likes)
  .joins("AND likes.user_id = #{current_user.id}").all.each { |article|
    print "#{article.name}"
    unless article.likes.empty?
      print " ( Liked at #{article.likes.first.created_at} )"
    end
    print "\n"
  }
# 
# たとえば Articles が全 3 件で「記事その2」だけ Like してないとすると ...
# ===
# 記事その1 ( Liked at 2017-10-19 23:00:38 UTC )
# 記事その2
# 記事その3 ( Liked at 2018-12-23 11:31:12 UTC )

上記例で 1 点注意があり eager_load().joins() 以降に .includes() を挟むと絞り込みがリセットされてしまう ことに注意。後続に eager_load を挟んでも preload を挟んでも大丈夫だが、なぜかはわかんないケド includes を挟むと joins の AND ... で絞った条件が解除されてしまう。includes をクエリにつけた段階で、前段までの LEFT OUTER JOIN 条件が再構築されるのかもしれない ... 。

ActiveRecord::Base.sanitize_sql

sanitize_sql_for_conditions のエイリアス

joins に限らないが、プリペアドステートメントのプレースホルダを手動で作成するやつ。この他 sanitize_sql_for_ordersanitize_sql_like とかも使えそう。

# モデルクラス内なら親クラス指定 ( ActiveRecord::Base ) いりません
User.eager_load(:likes)
  .joins(ActiveRecord::Base.sanitize_sql([
    'AND likes.user_id = :user_id AND likes.is_dislike = :is_dislike',
    user_id: 1,
    is_dislike: false,
  ])

Subquery by joins + where

ActiveRecordでサブクエリ(副問い合わせ)と内部結合

Rails の where は結構柔軟で where(model: {id: :relation}) みたいなものも突っ込める。

# タグ 'ruby' を保持する記事 ... を保持するユーザが全員欲しい

User
  .joins(:articles)   # ここは関連をロードする必要がなければ left_outer_joins でも OK
  .where(articles: {  # INNER JOIN した :articles で絞り込みをするぜ!
    id: Articles      # id: Relation で内部的に ID IN (SELECT ...) みたいなサブクエリが走る
          .joins(:tags)
          .where(tags: {
            name: 'ruby'
          })  
  })
  .all

left_outer_joins / left_joins

left_outer_joins

INNER JOIN ではなく LEFT OUTER JOIN で配下モデルが存在しなくても親レコードを取得したいときのやつ。

似たようなメソッドに eager_load があり、こちらは関連モデルを LEFT OUTER JOIN で先読みキャッシュする。preload との使い分けはケースバイケースだが、こちらは SQL 発行が 1 回で済むので、テーブルが巨大でない & JOIN はしたいケースではこちら。

# 以下では authors が posts を持っているかどうかにかかわらず
# すべての著者とその記事の数をグループで返す  
# 著者 A ( 0 ), 著者 B ( 11 ), 著者C ( 2 ) ... みたいな  
Author.left_outer_joins(:posts)
  .distinct.select('authors.*, COUNT(posts.*) AS posts_count')
  .group('authors.id')

# ページネーションでよくある limit & offset との合わせ技
# 配下関連モデルにより取得レコードに重複が発生する可能性があるので
# group や distinct による絞り込みをしている
page     = params[:page] ||= 1
per_page = 10
Feed.limit(per_page).offset(per_page * (page.to_i - 1))
  .order(feed_published_at: 'DESC')
  .left_outer_joins(:tags, resource: [:brand])
  .group('feeds.id')

# left_outer_join 時は自モデルに外部モデルのカラムがくっつかないので
# where とか厳密にやらんといかんです
# 以下は left_outer_join x 外部モデルの where in パターン
query = Article.left_outer_joins(:tags)
if params[:tags]  # クエリ文字列で ?tags[]=hoge&tags[]=fuga みたいなの受けてる想定
  query = query.where('tags.id in (:tags)', tags: params[:tags])  # プレースホルダ
end
@articles = query.pluck(:id, :title, :body)

includes

関連付けを一括読み込みする
ActiveRecord ~ 複数テーブルにまたがる検索

Active Record は関連モデルを「アクセスされたタイミングで」 SQL 発行して取得する。この機能に頼りすぎると N + 1 問題を引き起こすため、includes で予め関連をロードしてキャッシュしておくとパフォーマンス的によろしい。内部的には「後続クエリに絞り込みが入っているかどうか」で preloadeager_load を切り替えているだけ。

clients = Client.limit(10) 
clients.each do |client|
  puts client.address.postcode
  # rails はこのとき client がまだ保持してないネストした関連をよしなにクエリ実行して取得する
  # よって 11 のクエリ実行がおこり、レコード数が増加したときにオーバヘッドが大きくなる
end

# 上記コードを includes で解決する例
clients = Client.includes(:address).limit(10) 
clients.each do |client|
  puts client.address.postcode
  # client は既に address を保持しているためクエリ実行がおきない
  # clients 取得 + addresses.client_id IN (....) といった 2 回のクエリ実行で済む
end

# includes 後に where することも可能
Article.includes(:comments)
  .where(comments: { visible: true })  # 但しこの場合は joins を利用したほうがよい

# 階層が深いときはこうじゃ
User.includes(projects: [tasks: [:tags, :category]]).all

# ソートはこう ( ハッシュではなく文字列で "テーブル.カラム ASC/DESC" にする )
User.includes(resource: [:feeds]).order('feeds.feed_published_at DESC')

Scope Block Association + includes

ActiveRecordのincludesに条件を指定する
Conditional Eager Loading in Rails

includes 自体はただの preload / eager_load による関連モデルの事前キャッシュでしかない。このため eager_load + joins AND 条件追加で触れた CakePHP3 の Cake\ORM\Query::contain() + where() 条件クロージャのような 配下モデルを条件つきで INNER JOIN するけど、条件一致する配下モデルを持たない親モデルも LEFT OUTER JOIN っぽく欲しいケース は単体で対応できない。

includes のみでシンプルにこれを行いたいケースでは 予め親モデル側でスコープブロックを利用して、条件つき配下モデルを「仮想アソシエーション」として定義してフィルタリングした後に includes() で関連をキャッシュ する方法で対処するのがよい。こっちのが準備が面倒だけどスマート。

class User < ApplicationRecord
  has_many :articles, dependent: :destroy
  has_many :public_articles, -> {
    # Scope block of association.
    where is_public: true
  }, class_name: 'Article'
end

# Users に「公開記事があればくっつけて、なければないで」全員集合
@users = User.includes(:public_articles)

# Users のうち「公開記事がある人だけ」全員集合 ( 書き方違うだけで全部同じ結果 )
@users = User.includes(:articles).where(articles: { is_public: true })
@users = User.includes(:articles).where('articles.is_public = ?', true).references(:articles)
@users = User.includes(:articles).merge(Article.where(is_public: true)).references(:articles)

SubQuery by .from

#from - api.rubyonrails.org ActiveRecordを使ってFROM句のサブクエリを書く方法

複雑なサブクエリを FROM に書きたいときは .from に SQL 打ち込むのが割と何でも対応できる。

User.from(
  User.where(何かとても複雑な条件とか),
  :users  # ここはモデル名というより SQL で実際に吐かれるテーブル名だと思う
).where(id: 1)

ActiveSupport::Inflector.constantize

ActiveSupport::Inflector.constantize
ActiveSupportのconstantizeが便利
[Rails5] Active Support::Inflectorの便利な活用形メソッド群
The Safest Way to Constantize...

CakePHP なら可変変数を利用して $this->loadModel($model); して $this->{$model}->find(); みたいにする モデルクラスの動的ロード を Rails でどうやるかというヤツ。基本的にこの手のヤツは良い手段ではないので使いどころ注意。

# Item モデルが存在するとき、以下は Item.find(1) と同じ結果を返す
# 存在しない、同スコープ内で定数が定義済みの場合は例外を投げる
'Item'.constantize.find(1)

# エラー時に例外の代わりに nil を返す safe 版もある
'NotExistsClass'.safe_constantize  # nil

# Inflector モジュールの classify で事前にクラス名化してから
model_name = 'public-items'.classify  # PublicItem
@record    = model_name.constantize.find(1)

ActiveRecord::Caluculations

ActiveRecord::Calculations
Rails tips: ActiveRecord count系機能の基本と応用(翻訳)

Person.average(:age) # => 35.8
Person.group(:role_id).maximum(:age) # => {1 => 52, 2 => 38, 3 => 23}  ロール毎の最大出してくれてるね
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment