2013年11月16日土曜日

Railsで多階層のテーブル結合をfindするサンプル


上図のようにテーブルが多階層(3階層以上)で結合されている場合に、ActiveRecord の findメソッドでconditionsを指定する方法を考えてみる。
rails generate model gun name:string
rails generate model shidan gun:references name:string
rails generate model rentai shidan:references name:string
rake db:migrate
サンプルデータも投入しておく。
rails c
Gun.create(:name=>'1-gun')
Gun.create(:name=>'2-gun')
Shidan.create(:gun_id=>1, :name=>'1-shidan')
Shidan.create(:gun_id=>2, :name=>'2-shidan')
Rentai.create(:shidan_id=>1, :name=>'1-rentai')
Rentai.create(:shidan_id=>2, :name=>'2-rentai')
この状態で、2階層であれば、findメソッドで下記のようなconditionsを指定できる。
Rentai.all(:include => :shidan, :conditions => ["shidans.id = ?", 2])
しかし、なんとなく直感的に3階層の場合のconditionsを下記のように設定しても、うまくいかない。
Rentai.all(:include => :shidan.gun, :conditions => ["shidans.gun.id = ?", 2])

NoMethodError: undefined method `gun' for :shidan:Symbol
includeオプションについては、下記のページに記述があったので、 

Ruby on Railsあれこれ/findメソッドの:includeオプションの書き方
http://winter-tail.sakura.ne.jp/pukiwiki/index.php?Ruby%20on%20Rails%A4%A2%A4%EC%A4%B3%A4%EC%2Ffind%A5%E1%A5%BD%A5%C3%A5%C9%A4%CE%A1%A7include%A5%AA%A5%D7%A5%B7%A5%E7%A5%F3%A4%CE%BD%F1%A4%AD%CA%FD

下記のようにincludeを書き換えてみたものの、やはりダメである。
Rentai.all(:include => {:shidan => :gun}, :conditions => ["shidans.gun.id = ?", 2])

ActiveRecord::StatementInvalid: SQLite3::SQLException: no such column: shidans.gun.id: SELECT "rentais"."id" AS t0_r0, "rentais"."shidan_id" AS t0_r1, "rentais"."name" AS t0_r2, "rentais"."created_at" AS t0_r3, "rentais"."updated_at" AS t0_r4, "shidans"."id" AS t1_r0, "shidans"."gun_id" AS t1_r1, "shidans"."name" AS t1_r2, "shidans"."created_at" AS t1_r3, "shidans"."updated_at" AS t1_r4, "guns"."id" AS t2_r0, "guns"."name" AS t2_r1, "guns"."created_at" AS t2_r2, "guns"."updated_at" AS t2_r3 FROM "rentais" LEFT OUTER JOIN "shidans" ON "shidans"."id" = "rentais"."shidan_id" LEFT OUTER JOIN "guns" ON "guns"."id" = "shidans"."gun_id" WHERE (shidans.gun.id = 2)
ダメではあったものの、出力されたSQLには有益な情報が含まれているので、余計な個所を削って人間にとって見やすいように成形してみる。
SELECT 
  rentais.id,
  rentais.shidan_id,
  rentais.name,
  rentais.created_at,
  rentais.updated_at, 
  shidans.id,
  shidans.gun_id,
  shidans.name,
  shidans.created_at,
  shidans.updated_at,
  guns.id,
  guns.name,
  guns.created_at,
  guns.updated_at
FROM rentais
LEFT OUTER JOIN shidans ON shidans.id = rentais.shidan_id
LEFT OUTER JOIN guns ON guns.id = shidans.gun_id
WHERE (shidans.gun.id = 2)
結局のところ、conditonsではSQLのWHERE句の内容を切り出しているに過ぎず、rubyだからといってオブジェクト間の階層を難しく意識せず、SQLに忠実に、"guns.id"で指定すれば良いようである。

下記のように書き直すことで、意図した検索結果を得ることができた。
Rentai.all(:include => {:shidan => :gun}, :conditions => ["guns.id = ?", 2])

=> [#<Rentai id: 2, shidan_id: 2, name: "2-rentai", created_at: "2013-11-16 07:12:21", updated_at: "2013-11-16 07:12:21">]

2013年8月9日金曜日

RspecでFactory Methodパターンをテストするサンプル


工場の工員が製品を製造するというFactory Methodパターンのテストを考えてみる。
工員と製品は工場に所属していて、工員が製品を製造すると、工場の在庫(製品)が増える。

まずは必要なモデルを生成する。
rails g model koujou name:string
rails g model kouin koujou:references name:string
rails g model seihin koujou:references seihin_num:integer
rake db:migrate
工場モデルに工員、製品間の1対多の関係を設定しておく。
class Koujou < ActiveRecord::Base
  has_many :kouins
  has_many :seihins
end
次に、工員モデルに製品の製造メソッドを追加する。
class Kouin < ActiveRecord::Base
  belongs_to :koujou

  def seizou
    seihin = Seihin.new
    seihin.koujou_id = koujou_id
    max_seihin_num = Seihin.maximum(:seihin_num)
    if max_seihin_num.blank?
      seihin.seihin_num = 1
    else
      seihin.seihin_num = max_seihin_num + 1
    end
    return seihin
  end
end
下記のような感じでRspecのテストコードを記述してみる。
テストコード内でsaveしてもDBにはcommitされないので、製造後の製品の配列のサイズの確認は常に1で問題ない。
require 'spec_helper'

describe Koujou do
  it '製品製造後の在庫(製品)増加の確認.' do
    koujou = Koujou.new
    koujou.name = 'テスト'
    koujou.save

    p koujou.seihins

    kouin = Kouin.new
    kouin.koujou_id = koujou.id
    kouin.name = '浜松浜子'
    kouin.save

    seihin = kouin.seizou
    seihin.save

    p koujou.seihins

    expect(koujou).to have(1).seihins
  end
end
ところが、テストが通らない。
製造後の製品の配列のサイズは0のままである。
bundle exec rspec spec/models/koujou_spec.rb
Rack::File headers parameter replaces cache_control after Rack 1.5.
[]
[]
 [31mF [0m

Failures:

  1) Koujou 製品製造後の在庫(製品)増加の確認.
      [31mFailure/Error: [0m  [31mexpect(koujou).to have(1).seihins [0m
        [31mexpected 1 seihins, got 0 [0m
 [36m     # ./spec/models/koujou_spec.rb:23:in `block (2 levels) in <top (requir
ed)>' [0m

Finished in 0.53903 seconds
 [31m1 example, 1 failure [0m

Failed examples:

 [31mrspec ./spec/models/koujou_spec.rb:6 [0m  [36m# Koujou 製品製造後の在庫(製
品)増加の確認. [0m

Randomized with seed 17099
よく考えてみると前回の記事と関連した問題である。
子モデルの値を参照する毎にDBから最新の値を取得しているのではなく、インスタンスに変更が加わらない限りは一度DBから参照した値をそのまま保持しているので、最初に参照した時点で配列が空になっている状態をその後も返しているわけである。

下記のように、途中で子モデルの値を確認する余計なコードを外して再度実行すると、チェックの時点で初めてDBから値を取得するのでテストが通るようになる。
require 'spec_helper'

describe Koujou do
  it '製品製造後の在庫(製品)増加の確認.' do
    koujou = Koujou.new
    koujou.name = 'テスト'
    koujou.save

    kouin = Kouin.new
    kouin.koujou_id = koujou.id
    kouin.name = '浜松浜子'
    kouin.save

    seihin = kouin.seizou
    seihin.save

    expect(koujou).to have(1).seihins
  end
end
bundle exec rspec spec/models/koujou_spec.rb
Rack::File headers parameter replaces cache_control after Rack 1.5.
 [32m. [0m

Finished in 0.52803 seconds
 [32m1 example, 0 failures [0m

Randomized with seed 58919
なんとなく気持ち悪いので、下記のように、expectの内部で製造を行った上で、チェックする時点で明示的にDBから値を取得するようにして、差分の確認はbyというマッチャを使うと安定しそうだ。
require 'spec_helper'

describe Koujou do
  it '製品製造後の在庫(製品)増加の確認.' do
    koujou = Koujou.new
    koujou.name = 'テスト'
    koujou.save

    kouin = Kouin.new
    kouin.koujou_id = koujou.id
    kouin.name = '浜松浜子'
    kouin.save

    expect {
      seihin = kouin.seizou
      seihin.save
    }.to change{Koujou.find(koujou.id).seihins.size}.by(1)
  end
end
bundle exec rspec spec/models/koujou_spec.rb
Rack::File headers parameter replaces cache_control after Rack 1.5.
 [32m. [0m

Finished in 0.59903 seconds
 [32m1 example, 0 failures [0m

Randomized with seed 65120