View on GitHub

akkunchoi.github.com

Rails3 - Active Record Query Interface

ActiveRecordのクエリーインタフェースの解説 を参考に、コードを書いて確認していきます。

準備

bundlerでインストールします。

# Gemfile
source 'https://rubygems.org'
      
gem "activerecord", "~> 3.2.12"                             
gem 'sqlite3'

active_recordを単体で使えるようにセットアップします。

# main.rb
require 'rubygems'
require 'bundler/setup'

require "active_record"                                     

# database connection
ActiveRecord::Base.establish_connection(                    
  adapter:  "sqlite3",
  database: ":memory:"                                      
)

例で使用するマイグレーションとモデルを作成します。

# main.rb
# migration
class Init < ActiveRecord::Migration
  def self.up
    create_table(:clients){|t|
      t.string :name
      t.integer :orders_count
      t.timestamps
    }
    create_table(:orders){|t|
      t.references :client
      t.integer :price
      t.datetime :ordered_date
      t.timestamps
    }
    create_table(:addresses){|t|
      t.references :client
      t.string :pref
    }
  end
  def self.down
    drop_table :clients
    drop_table :orders
    drop_table :addresses
  end
end

# run migrate
Init.migrate(:up)

# models
class Client < ActiveRecord::Base
  has_one :address
  has_many :orders
end
class Address < ActiveRecord::Base
  belongs_to :client
end
class Order < ActiveRecord::Base
  belongs_to :client, :counter_cache => true
end

準備が整いました。データを入れてみます。

# main.rb
Client.create({:name => "Alice"})
Client.create({:name => "Bob"})
Client.create({:name => "Carol"})
Address.create({:client => Client.find(1), :pref => "Osaka"})
Order.create({:client => Client.find(2), :price=> 20})
Order.create({:client => Client.find(2), :price=> 50})

SQLを確認するためにloggerを設定します。

# main.rb
ActiveSupport::LogSubscriber.colorize_logging = false
ActiveRecord::Base.logger = Logger.new(STDOUT)

オブジェクトをひとつだけ取り出す

find - 主キーによる検索

普通は id で検索します。

# find
puts Client.find(1).name
# => SELECT "clients".* FROM "clients" WHERE "clients"."id" = ? LIMIT 1  [["id", 1]]
# "Alice"

もしデータが見つからなかった場合はActiveRecord::RecordNotFound例外が発生します。

# find but not found
begin
  Client.find(100)
rescue ActiveRecord::RecordNotFound => e
  p e
  #<ActiveRecord::RecordNotFound: Couldn't find Client with id=100> 
end

以前はこの find の引数に色々パラメータを入れて複雑な条件をするのが主流でした。現在では後述のActiveRecord::Relationを使う方が良いです。 find はもっぱら、主キーからオブジェクトを取得する目的だけに使われるようになったようです。

first

最初の項目を取得します。LIMIT 1と同じです。 firstはレコードが存在していなければnilを返しますが、first!にすると、ActiveRecord::RecordNotFound例外が発生します。

# first
puts Client.first.name
# => SELECT "clients".* FROM "clients" LIMIT 1
# "Alice"

last

最後の項目を取得します。降順にしてLIMIT 1にすることで取得しています。 lastはレコードが存在していなければnilを返しますが、last!にすると、ActiveRecord::RecordNotFound例外が発生します。

# last
puts Client.last.name
# => SELECT "clients".* FROM "clients" ORDER BY "clients"."id" DESC LIMIT 1 
# "Carol"

複数のオブジェクトを取り出す

id の配列をfindすると一度に複数のオブジェクトを取得できます。

# find(array)
p Client.find([1,2])
# => SELECT "clients".* FROM "clients" WHERE "clients"."id" IN (1, 2)
# [#<Client id: 1, name: "Alice">, #<Client id: 2, name: "Bob">]

ひとつでもレコードが見つからなければやはりActiveRecord::RecordNotFoundです。

find, first, last は ActiveRecord::FinderMethods で定義されています。

複数のオブジェクトをまとめて処理する

テーブル内のレコード全件を処理したい場合はallメソッドを使うことができます。

# all
Client.all.each do |c|
  # ...
end

ただ、allはテーブルの全データを取得して、インスタンス化し、メモリ内に保持するので、何千件もあるとすぐメモリ不足になります。 (allは実行された時点で、データベースにクエリーを投げます。戻り値はActiveRecord::Relationではありません。ここは間違いやすいので注意です)

この問題を解決するために、find_eachfind_in_batchesという2通りの方法が用意されています。

find_each

全件をいくつかのブロックに分けて処理していくので、効率的に全件処理できます。。デフォルトでは1000件ごとです。 findの標準的なオプション(:order, :limitを除く)が使用できます。

# find_each
Client.find_each(:include => :address) do |c|
  p c
end
# SELECT "clients".* FROM "clients" WHERE ("clients"."id" >= 0) ORDER BY "clients"."id" ASC LIMIT 1000
# SELECT "addresses".* FROM "addresses" WHERE "addresses"."client_id" IN (1, 2, 3) 

#<Address id: 1, client_id: 1, pref: "Osaka">                              
# nil
# nil

# :start, :batch_size オプションが追加で使用できる
Client.find_each(:start => 2000, :batch_size => 5000) do |c|
  # ...
end

:startはバッチを再開する場合や、並列してワーカーを実行する場合などに利用できます。

find_in_batches

find_eachと似てますが、こちらはブロックの引数が配列になります。

# find_in_batches
Client.find_in_batches(:include => :orders, :batch_size => 2) do |clients|
  puts clients.size
end

# 一回目のバッチ
# SELECT "clients".* FROM "clients" WHERE ("clients"."id" >= 0) ORDER BY "clients"."id" ASC LIMIT 2
# SELECT "addresses".* FROM "addresses" WHERE "addresses"."client_id" IN (1, 2)
# => 2

# 二回目のバッチ
# SELECT "clients".* FROM "clients" WHERE ("clients"."id" > 2) ORDER BY "clients"."id" ASC LIMIT 2
# SELECT "addresses".* FROM "addresses" WHERE "addresses"."client_id" IN (3)
# => 1

ActiveRecord::Relation

条件を指定して取得するには以下のメソッドを使います。これらはActiveRecord::Relationオブジェクトを返します。

  • where
  • select
  • group
  • order
  • reorder
  • reverse_order
  • limit
  • offset
  • joins
  • includes
  • lock
  • readonly
  • from
  • having
p Client.where("1").class
# ActiveRecord::Relation

find に同じようなオプションを与えることもできます(古いやり方)。

Where

SQLのWhere文を構築します。String, Array, Hash のどれかを引数に入れて使います。

Stringの場合、SQLを直接書くようなイメージです。エスケープなどはされません。ユーザー入力値をそのまま入れないようにしましょう。

# where(string)
Client.where("orders_count = '2'")
# SELECT "clients".* FROM "clients" WHERE (orders_count = '2')
# => [#<Client id: 2, name: "Bob", orders_count: 2, created_at: "2013-03-06 19:12:44", updated_at: "2013-03-06 19:12:44">]

# これはやってはいけない!
Client.where("first_name LIKE '%#{params[:first_name]}%'")

Arrayにすると、プレースホルダーが使えます。? にエスケープされた値が入るので安全です。 詳細

# where(array, ...)
Client.where("orders_count = ?", params[:orders])

プレースホルダーはハッシュにもできます。

# where(array, hash)
Client.where("created_at >= :start_date AND created_at <= :end_date",
  {:start_date => params[:start_date], :end_date => params[:end_date]})

Hashにするとすっきりします。

# where(hash)
Client.where(:orders_count => 2)

範囲指定もできます。

# where(key => range)
Client.where(:created_at => (Time.now.midnight - 1.day)..Time.now.midnight)
# sql: 
# SELECT * FROM clients WHERE (clients.created_at BETWEEN '2008-12-21 00:00:00' AND '2008-12-22 00:00:00')

Subset条件(IN構文)を使うには、値を配列にします

# where(key => array)
Client.where(:orders_count => [1,3,5])
# sql:
# SELECT * FROM clients WHERE (clients.orders_count IN (1,3,5))

Order

SQL の ORDER BY そのままです。

# order by
Client.order("created_at")
Client.order("orders_count ASC, created_at DESC")

Select

SELECT句です。これを指定すると、取得されたオブジェクトは Readonly になります。

# select
Client.select(:orders_count)
# sql: 
# SELECT orders_count FROM "clients"

selectとした列以外を取得しようとすると、ActiveRecord::MissingAttributeError になります。

# select error
c = Client.select(:orders_count).first
begin
  p c.name
rescue ActiveModel::MissingAttributeError => e
  p e
end
# #>ActiveModel::MissingAttributeError: missing attribute: name

SELECT DISTINCT 相当は uniq() です。

# select distinct
Client.select(:name).uniq
# sql:
# SELECT DISTINCT name FROM clients

q = Client.select(:name).uniq
q = uniq(false) # uniq解除

Limit, Offset

LIMIT/OFFSET句です。

# limit, offset
Client.limit(5)
Client.offset(30)

Group

# group by
Client.group("date(created_at)")
# sql: SELECT "clients".* FROM "clients" GROUP BY date(created_at)

Having

# having
Order.select("date(created_at) as ordered_date, sum(price) as total_price")
  .group("date(created_at)")
  .having("sum(price) > ?", 100)
# sql: SELECT date(created_at) as ordered_date, sum(price) as total_price FROM "orders" GROUP BY date(created_at) HAVING sum(price) > 100

上書き - Overriding

構築したクエリから一部を除外したり、特定の条件だけにするメソッドが用意されてます。

  • except(): 指定クエリを除外(:order, :whereなど)
  • only(): 指定クエリだけにする(:order, :whereなど)
  • reorder(): default scope で指定した order を上書きします
  • reverse_order(): 逆順にします

exceptを使ってみます。例外的な処理が発生する場合に使えるかもです。

# except
clients = Client.where("orders_count > 0")
p clients
# [#<Client id: 2, name: "Bob">]

clients = clients.except(:where)
p clients
# [#<Client id: 1, name: "Alice">, #<Client id: 2, name: "Bob">, #<Client id: 3, name: "Carol">]

読み込み専用 - Readonly

Readonlyなオブジェクトを更新しようとすると、例外が発生します。

# readonly
client = Client.readonly.first
client.name = "hoge"
client.save # raise ActiveRecord::ReadOnlyRecord

ロック - Locking

楽観的ロック

integer型のlock_versionという名前のカラムが存在すると、Railsが自動的に楽観的ロックを行なってくれます。 更新する度にlock_versionの値をインクリメントしていき、競合を検知する仕組みです。

更新が競合した場合、ActiveRecord::StaleObjectErrorが発生します。

# lock_version
c1 = Client.find(1)
c2 = Client.find(1)
 
c1.first_name = "Michael"
c1.save
 
# sql: 
# begin transaction
# UPDATE "clients" SET "name" = 'Michael', "updated_at" = '2013-03-13 17:17:59.886521', "lock_version" = 1 WHERE ("clients"."id" = 1 AND "clients"."lock_version" = 0)
# commit transaction

c2.name = "should fail"
c2.save # Raises an ActiveRecord::StaleObjectError

ActiveRecord::Base.lock_optimistically = false でこの機能を無効にできます。

set_locking_column で既定のlock_versionというカラム名を変更できます。

# set_locking_column
class Client < ActiveRecord::Base
  set_locking_column :lock_client_column
end

悲観的ロック

悲観的ロックはデータベースシステムの機能を利用します。

リレーションを構築する時にlockを使うと、選択行の排他的ロックを獲得します。 lockを使ったリレーションはデッドロックを避けるために、通常transactionブロックで囲みます。

# transaction
Address.transaction do
  a = Address.lock.first
  a.pref = "Hokkaido"
  a.save
end
# begin transaction
# UPDATE "addresses" SET "pref" = 'Hokkaido' WHERE "addresses"."id" = 1
# commit transaction

共有ロックなどロックタイプを変更したい場合は引数にタイプを与えてやります。

# lock in share mode
Address.transaction do
  a = Address.lock("LOCK IN SHARE MODE").first
  a.increment!(:views)
end

すでにインスタンスを取得しているなら、with_lockでトランザクションを開始できます。

# with_lock
a = Address.first
a.with_lock do
  # このブロックはtransaction内で、itemはロックされてます
  a.increment!(:views)
end

Join

TODO