カウンセリングセッションのモデル
さて、ここから、業務要件に従って、予約用の時間枠をCounselingSessionというモデルクラスとして作成します。
ちょっとネタバラシをすると、ここに書いているのは、完全に作業経過そのままではなくて、若干、予習して問題点をクリアしてから戻ってやり直しながら、それを貼り付けています。この項目については、昨日のうちにRailsのリレーションの機能をいろいろ試しています。
で、まず、モデルクラスの生成。
$ ruby script/generate model CounselingSession exists app/models/ exists test/unit/ exists test/fixtures/ create app/models/counseling_session.rb create test/unit/counseling_session_test.rb create test/fixtures/counseling_sessions.yml exists db/migrate create db/migrate/002_create_counseling_sessions.rb
ここで驚いたのが、migrateのスキーマの雛形を自動的に作ってくれること。
最初に、スキーマファイルは一本にまとめると言いましたが、ここまでやってくれるならそこに乗るしかありません。それで、生成された db/migrate/002_create_counseling_sessions.rbに以下を追加。
class CreateCounselingSessions < ActiveRecord::Migration def self.up create_table :counseling_sessions, :options => 'CHARACTER SET utf8' do |t| t.column "counselor_id", :integer, :null => false t.column "client_id", :integer t.column "status", :integer, :null => false, :default => 0 t.column "start", :datetime, :null => false t.column "end", :datetime, :null => false t.column "place", :string, :limit => 20 t.column "memo", :string, :limit => 40 end add_index "counseling_sessions", ["counselor_id", "start"], :name => "counseling_sessions_counselor_index" add_index "counseling_sessions", ["client_id"], :name => "counseling_sessions_client_index" end def self.down drop_table :counseling_sessions end end
ついでに、データベース再構築をスクリプト化します。
まずは、データベース自体の構築用sqlスクリプト(db/create_database.sql)
drop database if exists coreserve_development ; create database coreserve_development ; grant all on coreserve_development.* to coreserve@localhost identified by '*****' ; drop database if exists coreserve_test ; create database coreserve_test ; grant all on coreserve_test.* to coreserve@localhost identified by '*****' ; drop database if exists coreserve_production ; create database coreserve_production ; grant all on coreserve_production.* to coreserve@localhost identified by '******' ;
rakeから実行できるようにRakefileに以下を追加します。
task :create_database do sh "mysql -uroot -p < db/create_database.sql" end task :re_init_db => [ :create_database, :migrate ]
こうすると、次のコマンドを打つだけで、データベースが再構築されます。
$ rake re_init_db
次は、テーブル間のリレーションの定義。
# app/models/user.rb class Counselor < User has_many :counseling_sessions def counselor? true end end class Client < User has_many :counseling_sessions def counselor? false end end # app/models/counseling_session.rb class CounselingSession < ActiveRecord::Base belongs_to :counselor belongs_to :client end
これで、CounselorとCounselingSessionの間、ClientとCounselingSessionの間にそれぞれ、1対多の関連ができあがります。
これをテストしますが、CounselingSessionのテストの為には、CounselorとClientのオブジェクトが必要になるので、fixtureで作成してみます。
class CounselingSessionTest < Test::Unit::TestCase fixture :counselors, :table_name => 'users', :class_name => 'Counselor' fixture :clients, :table_name => 'users', :class_name => 'Client' fixtures :counseling_sessions def setup @co1 = Counselor.find_first ['login = ?', 'co1'] @cl1 = Client.find_first ['login = ?', 'cl1'] @cl2 = Client.find_first ['login = ?', 'cl2'] end def test_fixture assert @co1 assert_equal Counselor, @co1.class assert_equal 'co1', @co1.login assert @cl1 assert_equal Client, @cl1.class assert_equal 'cl1', @cl1.login assert @cl2 assert_equal Client, @cl2.class assert_equal 'cl2', @cl2.login end end
これを動かしてエラーになるのを確認してから、fixtureを作成します。
# test/fixtures/counselors.yml co1: type: Counselor id: 100 login: co1 verified: 1 # test/fixtures/clients.yml cl1: type: Client id: 201 login: cl1 verified: 1 cl2: type: Client id: 202 login: cl2 verified: 1
再度テストを実行し、fixtureが正しく作成されているのを確認してから、CounselingSessionの作成をテストします。
class CounselingSessionTest < Test::Unit::TestCase ... def test_create_by_hand s = CounselingSession.new t = Time.now s.start = t s.end = 60.minute.since(t) s.place = 'aaaa' s.memo = 'bbbb' assert s.save @co1.counseling_sessions << s # データベースから読み直す s = CounselingSession.find_first(['start = ?', t]) assert_equal('co1', s.counselor.login) assert_equal(0, s.status) assert_equal(nil, s.client) assert_equal(t.to_i, s.start.to_i) assert_equal(t.since(60.minute).to_i, s.end.to_i) assert_equal('aaaa', s.place) assert_equal('bbbb', s.memo) end end
60.minute.since(t)なんて書けるのは、RailsのActiveSupportの機能ですが、これはなかなかカッコイイですね。
これはいかにも冗長なので メソッド化しましょう。予約枠の生成はCounselorが行なうので、Counselorのメソッドとして実装しますが、これも先にテストを作ります。
def test_create t = Time.now s = @co1.create_session(t, 60) # データベースから読み直す s = CounselingSession.find_first(['start = ?', t]) assert_equal('co1', s.counselor.login) assert_equal(0, s.status) assert_equal(nil, s.client) assert_equal(t.to_i, s.start.to_i) assert_equal(t.since(60.minute).to_i, s.end.to_i) assert_equal(nil, s.place) assert_equal(nil, s.memo) t = 120.minutes.since(t) s = @co1.create_session(t, 30) do |ss| ss.place = 'aaaa' ss.memo = 'bbbb' end # データベースから読み直す s = CounselingSession.find_first(['start = ?', t]) assert_equal('co1', s.counselor.login) assert_equal(0, s.status) assert_equal(nil, s.client) assert_equal(t.to_i, s.start.to_i) assert_equal(t.since(30.minute).to_i, s.end.to_i) assert_equal('aaaa', s.place) assert_equal('bbbb', s.memo) end
create_sessionのパラメータとしては、開始時刻と時間(分)が必須で、それ以外の項目の設定が必要な時はブロックの中で設定することにしました。
では、これを実装します。
class Counselor < User has_many :counseling_sessions def counselor? true end def create_session(start, length, &block) s = CounselingSession.new s.start = start s.end = length.minutes.since(start) block.call(s) if block_given? s.save! counseling_sessions << s s end end
次に、ダブルブッキングがエラーになるようにしますが、これもテストから実装します。
def test_double_booking t = Time.now s = @co1.create_session(t, 60) cnt = CounselingSession.count assert_raise(RuntimeError) do @co1.create_session(t+1, 60) end assert_equal(cnt, CounselingSession.count) assert_raise(RuntimeError) do @co1.create_session(30.minutes.since(t), 60) end assert_equal(cnt, CounselingSession.count) end
では、チェック機能を実装します。
class Counselor < User has_many :counseling_sessions def counselor? true end def create_session(start, length, &block) s = CounselingSession.new s.start = start s.end = length.minutes.since(start) check_double_booking(s) block.call(s) if block_given? s.save! counseling_sessions << s s end private def check_double_booking(s) ss = CounselingSession.find_first(['counselor_id = ? and start < ? and end > ?', self.id, s.start, s.start]) raise "double booking #{s.start}" if ss ss = CounselingSession.find_first(['counselor_id = ? and start < ? and end > ?', self.id, s.end, s.end]) raise "double booking #{s.end}" if ss end end
実装しながら、「カウンセラーが違えば時間が重なってもいい」ということに気がついたので、そのテストも追加しました。
def test_double_booking_with_two_counselor t = Time.now s = @co1.create_session(t, 60) cnt = CounselingSession.count assert_raise(RuntimeError) do @co1.create_session(t+1, 60) end assert_equal(cnt, CounselingSession.count) co2 = Counselor.create(:login => "co2", :email => "co2@a.com") co2.create_session(t+1, 60) assert_equal(cnt+1, CounselingSession.count) end
次に予約の操作をClientのメソッドとして実装します。同じくテストから先に実装。
def test_reserve t = Time.now s = @co1.create_session(t, 60) @cl1.reserve(s) # データベースから読み直す s = CounselingSession.find_first(['start = ?', t]) assert_equal('co1', s.counselor.login) assert_equal(1, s.status) assert_equal(@cl1.login, s.client.login) assert_equal(t.to_i, s.start.to_i) assert_equal(t.since(60.minute).to_i, s.end.to_i) end
メソッド本体の実装。
class Client < User has_many :counseling_sessions def counselor? false end def reserve(s) raise "already reserved" if s.status == :reserved raise "can't change status" if s.status != :free s.status = :reserved Client.transaction do s.save! self.counseling_sessions << s end end end
ここで、s.status = :reserved のような書き方をしたいので、テーブル上のカラムはstatus_codeという名前にして、アプリケーションからは、シンボルで使えるように変更しました。
class CounselingSession < ActiveRecord::Base belongs_to :counselor belongs_to :client StatusToStatusCode = { :free => 0, :reserved => 1, :done => 2, :not_done => 3 } StatusCodeToStatus = { 0 => :free, 1 => :reserved, 2 => :done, 3 => :not_done } def status StatusCodeToStatus[self.status_code] end def status=(s) code = StatusToStatusCode[s] raise "illeagal status #{s}" unless code self.status_code = code end end
def test_reserve_error t = Time.now s = @co1.create_session(t, 60) @cl1.reserve(s) assert_raise(RuntimeError) do @cl1.reserve(s) end s.status = :done s.save! assert_raise(RuntimeError) do @cl1.reserve(s) end s.status = :not_done s.save! assert_raise(RuntimeError) do @cl1.reserve(s) end end
これで、テストがOKになったら、予約できないステータスでのエラーのテストも入れます。
def test_reserve_error t = Time.now s = @co1.create_session(t, 60) @cl1.reserve(s) assert_raise(RuntimeError) do @cl1.reserve(s) end s.status = :done s.save! assert_raise(RuntimeError) do @cl1.reserve(s) end s.status = :not_done s.save! assert_raise(RuntimeError) do @cl1.reserve(s) end end
次はキャンセルです。正常系テスト、実装、異常系テストの順に作っていきます。
業務要件で、開始時刻を過ぎたらキャンセルはできないことになっています。それをチェックする為に、現在時刻をパラメータとして渡します。パラメータ化することによって、テストが楽になります。
def test_cancel t = Time.now s = @co1.create_session(t, 60) @cl1.reserve(s) @cl1.cancel(s, 1.ago(t)) # データベースから読み直す s = CounselingSession.find_first(['start = ?', t]) assert_equal('co1', s.counselor.login) assert_equal(:free, s.status) assert_equal(nil, s.client) assert_equal(false, @cl1.counseling_sessions.include?(s)) end
def cancel(s, now=Time.now) raise "not reserved" unless s.status == :reserved raise "not my resevation" unless s.client == self raise "already started" if s.start < now s.status = :free Client.transaction do s.save! self.counseling_sessions.delete(s) end end
def test_cancel_error t = Time.now s = @co1.create_session(t, 60) # 予約前にキャンセル assert_raise(RuntimeError) do @cl1.cancel(s) end @cl1.reserve(s) @cl1.cancel(s, 1.ago(t)) # キャンセル後に再度キャンセル assert_raise(RuntimeError) do @cl1.cancel(s) end # 別人がキャンセル @cl1.reserve(s) assert_raise(RuntimeError) do @cl2.cancel(s) end # データベースから読み直す s = CounselingSession.find_first(['start = ?', t]) assert_equal('co1', s.counselor.login) assert_equal(:reserved, s.status) assert_equal(@cl1, s.client) assert_equal(true, @cl1.counseling_sessions.include?(s)) end
次に表示関係の機能をモデルのメソッドにして行きます。まず、表示用のデータをfixtureで追加します。
free1: id: 10001 couselor_id: 100 status_code: 0 start: 2006-05-30 10:00 end: 2006-05-30 11:00 memo: free1 free2: id: 10002 couselor_id: 100 status_code: 0 start: 2006-05-30 13:00 end: 2006-05-30 14:00 memo: free2 reserved1: id: 10003 couselor_id: 100 client_id: 201 status_code: 1 start: 2006-06-01 13:00 end: 2006-06-01 14:00 memo: reserved1 done1: id: 10004 couselor_id: 100 client_id: 201 status_code: 2 start: 2006-05-31 13:00 end: 2006-05-31 14:00 memo: done1 not_done1: id: 10005 couselor_id: 100 status_code: 3 start: 2006-05-20 13:00 end: 2006-05-20 14:00 memo: not_done1
このfixtureのデータをそのままステータス別に取り出すテスト。カウンセラーとクライアントでそれぞれ自分に関係するセッションだけ取り出せるかテストします。
def test_list_for_counselor l = @co1.list_sessions(:free) assert_equal(2, l.size) assert_equal('free2', l[0].memo) assert_equal('free1', l[1].memo) l = @co1.list_sessions(:reserved) assert_equal(1, l.size) assert_equal('reserved1', l[0].memo) assert_equal(@cl1, l[0].client) l = @co1.list_sessions(:done) assert_equal(1, l.size) assert_equal('done1', l[0].memo) assert_equal(@cl1, l[0].client) l = @co1.list_sessions(:not_done) assert_equal(1, l.size) assert_equal('not_done1', l[0].memo) assert_equal(nil, l[0].client) end def test_list_for_client l = @cl1.list_sessions(:reserved) assert_equal(1, l.size) assert_equal('reserved1', l[0].memo) assert_equal(@cl1, l[0].client) l = @cl1.list_sessions(:all) assert_equal(2, l.size) assert_equal(@cl1, l[0].client) assert_equal(@cl1, l[1].client) assert_equal('done1', l[1].memo) assert_equal('reserved1', l[0].memo) end
実装は、ベースクラスのUserに定義すると、一つのコードでCounselor用とClient用両方をサポートします。
class User < ActiveRecord::Base include LoginEngine::AuthenticatedUser def list_sessions(status) if status == :all counseling_sessions.find(:all, :order =>' start desc') else counseling_sessions.find(:all, :conditions => ['status_code = ?', CounselingSession::StatusToStatusCode[status]], :order=>'start desc') end end end
実装は一つですが、Counselorオブジェクトに対してこれを呼ぶと、counselor_idがこれと一致するもの、Clientオブジェクトに対して呼ぶと、client_idが一致するものを呼び出します。counseling_sessionsというメソッドが、子クラスでそれぞれ定義されていて、別の動きをするわけです。
ActiveRecoredの威力を見せつけられた感じです。
最後に、空き時間の全件出力です。
def test_list_all_free l = CounselingSession.list_free assert_equal(2, l.size) assert_equal(:free, l[0].status) assert_equal(:free, l[1].status) assert_equal(nil, l[0].client) assert_equal(nil, l[1].client) # もう一人カウンセラーを追加して、枠を作ってみる co2 = Counselor.create(:login=>'co2', :email=>'co2@a.com') t = Time.now co2.create_session(t, 60) l = CounselingSession.list_free assert_equal(3, l.size) l.each do |s| assert_equal(:free, s.status) assert_equal(nil, s.client) end assert_equal(co2, l[0].counselor) assert_equal(@co1, l[1].counselor) assert_equal(@co1, l[2].counselor) end
class CounselingSession < ActiveRecord::Base .... def self.list_free self.find(:all, :conditions => ['status_code = ?', CounselingSession::StatusToStatusCode[:free]], :order=>'start desc') end ... end
ここはだいぶ長くなりましたが、モデル(ビジネスロジック)は、これでほぼ全部完成したと思います。
ActiveRecoredの威力で、かなり簡単にビジネスロジックが書けることと、データベースを使うのにテストが容易なことを実感しました。
本来は、テストはもう少し細かくして、テスト項目ごとにテストメソッドを分けるべきだと思います。また異常系のテストをもっと網羅的にやるべきかもしれませんが、そこは手抜きしました。