Ruby on Railsで複合キーを扱う(3)
2012/03/27
前回は、Gemライブラリcomposite_primary_keysを用いることにより、Ruby on Railsでも複合キーを持つテーブルを扱うWebアプリケーションを構築できそうであることを示しました。
ただ、前々回と前回で前提としたデータベーステーブルは、文字列や整数値を組み合わせて主キーとしているという特徴がありました。
今回は「期間」を用いてレコードを特定するタイプのテーブルをRailsでどう扱うか、というテーマで書きます。
「期間でレコードを特定する」とは
「期間でレコードを特定する」とはどういうことでしょうか。言葉で説明すると長くなるので、ソースコードで示しましょう。
第1回で作成したdb/migrate/...create_departments.rbを次のように修正してください。
class CreateDepartments < ActiveRecord::Migration
def change
create_table :departments, id: false do |t|
t.string :code, null: false
t.string :name, null: false
t.date :started_on, null: false
t.date :ended_on, null: true
t.timestamps
end
add_index :departments, [ :code, :started_on ], unique: true
end
end
これまでは、code, seq_number という2つのカラムの組み合わせでレコードを特定していたのですが、seq_number の代わりに started_on, ended_on という期間でレコードを特定します。
このdepartmentsテーブルは、たとえば会社の「部門」を表しています。会社というのは組織改編がありますから、部門の名前その他の属性がいろいろと変わりますね。たとえば、2000年に「ロボット製造部」ができて、2001年に「ロボット事業部」になり、また2002年から「ロボット事業本部」になった、みたいなことです。
この状況をRubyコードで表現すれば、次のようになります。
Department.create!(code: "robot", name: "ロボット製造部", started_on: Date.new(2000, 1, 1), ended_on: Date.new(2001, 1, 1)) Department.create!(code: "robot", name: "ロボット事業部", started_on: Date.new(2001, 1, 1), ended_on: Date.new(2002, 1, 1)) Department.create!(code: "robot", name: "ロボット事業本部", started_on: Date.new(2002, 1, 1), ended_on: nil)
同じコード(code)を持つ部門に関しては開始日(started_on)と終了日(ended_on)で表される「期間」が重ならないようにレコードを維持すれば、
DurationLimited.current_date = Date.new(2001, 8, 1)
dep = Department.find("robot")
puts dep.name # "ロボット事業部"
のような感じで事業部を特定できるはずです。2001年8月1日に存在していたのは2番目に作ったオブジェクトです。
DurationLimitedモジュールは、あとで作ります。
「期間」による関連づけ
製品(product)についても同様にコードと期間で特定できるようにしましょう。db/migrate/...create_products.rbを次のように修正してください。
class CreateProducts < ActiveRecord::Migration
def change
create_table :products, id: false do |t|
t.string :code, null: false
t.string :department_code, null: false
t.string :name, null: false
t.text :description
t.date :started_on, null: false
t.date :ended_on, null: true
t.timestamps
end
add_index :products, [ :code, :started_on ], unique: true
end
end
修正前のテーブルに存在したmodel_numberカラムを取って、代わりにstarted_on, ended_onカラムを追加しています。また、この製品が所属する部門を参照するためにdepartment_codeカラムとdepartment_seq_numberカラムを持っていましたが、department_seq_numberカラムは不要です。なぜなら、ある日付におけるある製品のレコードを取得したとして、それと関連づけられる部門は部門コードとその日付で特定できるからです。
たとえば、次のようなRubyコードで表現される2つの製品(いずれも製品コードは"bob")を考えます。
Product.create!(code: "bob", name: "ボブ2001", department_code: "robot", started_on: Date.new(2001, 4, 1), ended_on: Date.new(2004, 4, 1)) Product.create!(code: "bob", name: "ボブXP", department_code: "robot", started_on: Date.new(2004, 4, 1), ended_on: nil)
これに関して、次のような操作ができるといいですね。
DurationLimited.current_date = Date.new(2003, 8, 1)
p = Product.find("bob")
puts p.department.name # "ロボット事業本部"
puts p.department.products.size # 1
準備作業
いろいろと調べた結果、このような「期間」を利用してレコードを特定するタイプのデータベースを扱うのにGemライブラリcomposite_primary_keysは向いていないようなので、今回は外してしまいます。Gemfile から次の行を除去してください。
gem "composite_primary_keys", "~> 5.0.4"
それから、app/models/department.rbの中身を空にしてください。
class Department < ActiveRecord::Base end
同様に、app/models/product.rbの中身を空にしてください。
class Product < ActiveRecord::Base end
そして、マイグレーションをやり直します。
% rake db:migrate:reset
RSpecによる試験コード
ここからはテスト駆動開発のやり方で行きましょう。まず、RSpecのコードを書きます。
spec/model/department_spec.rb を次のように書き換えます。
# coding: utf-8
require 'spec_helper'
describe Department do
let(:department0) { FactoryGirl.create(:department,
code: "robot", name: "Department0",
started_on: Date.new(2000, 1, 1), ended_on: Date.new(2002, 1, 1)) }
let(:department1) { FactoryGirl.create(:department,
code: "robot", name: "Department1",
started_on: Date.new(2002, 1, 1), ended_on: nil) }
let(:department2) { FactoryGirl.create(:department,
code: "ship", name: "Department1",
started_on: Date.new(2002, 1, 1), ended_on: nil) }
before do
department0
department1
department2
end
it "部門(department)を日付と部門コードで検索できる" do
DurationLimited.current_date = Date.new(2001, 1, 1)
dep0 = Department.find("robot")
dep0.name.should == department0.name
DurationLimited.current_date = Date.new(2003, 1, 1)
dep1 = Department.find("robot")
dep1.name.should == department1.name
end
end
また、spec/factories/departments.rb を次のように書き換えます。
FactoryGirl.define do
factory :department do
sequence(:code) { |n| "department_%02d" % n }
name { code.camelize }
started_on { 2.years.ago }
end
end
とりあえず、spec/models/product_spec.rb と spec/factories/products.rb は削除しておきます。
実装開始
さあ、実装開始です。何はともあれ、RSpecを動かしてみましょう。
% rake spec
(省略)
Failures:
1) Department 部門(department)を日付と部門コードで検索できる
Failure/Error: DurationLimited.current_date = Date.new(2001, 1, 1)
NameError:
uninitialized constant DurationLimited
# ./spec/models/department_spec.rb:23:in `block (2 levels) in <top (required)>'
Finished in 0.02483 seconds
1 example, 1 failure
定数DurationLimitedが未定義だと言っていますね。
新規ファイルapp/models/duration_limited.rbを次のような内容で作成してください。
module DurationLimited mattr_accessor :current_date end
% rake spec
(省略)
Failures:
1) Department 部門(department)を日付と部門コードで検索できる
Failure/Error: dep0 = Department.find("robot")
ActiveRecord::StatementInvalid:
SQLite3::SQLException: no such column: departments.:
SELECT "departments".* FROM "departments" WHERE "departments"."" = ? LIMIT 1
# ./spec/models/department_spec.rb:25:in `block (2 levels) in <top (required)>'
Finished in 0.0254 seconds
1 example, 1 failure
まあ、想定通りです。
findメソッドの実装
途中経過は除いて、findメソッドを実装してしまいます。
まず、app/models/duration_limited.rbを次のように修正します。
module DurationLimited
extend ActiveSupport::Concern
mattr_accessor :current_date
included do
default_scope do
where("started_on <= ? AND (ended_on > ? OR ended_on IS NULL)",
DurationLimited.current_date, DurationLimited.current_date)
end
end
module ClassMethods
def find(code)
record = self.where(code: code).first
raise ActiveRecord::RecordNotFound unless record
record
end
end
end
[訂正] 読者の方からの指摘を受けて、ソースコードの一部を修正しました。修正前はwhereメソッドの内部でended_on >= ?と書いていましたが、正しくはended_on > ?です。なお、条件をそのままにしてended_onカラムの値を「2001-01-01」から「2000-12-31」に直す、という対処法もありますが、私は条件式を修正する方がいいと思います。確かにended_onカラムの意味は「終了日」なので、日常的な感覚では「2000-12-31」がふさわしいかもしれません。しかし、Date型をDateTime型のサブセットと考えると、「2000-12-31」という日付は2000年12月31日の00時00分00秒と同じであるとみなせます。2001年1月1日から部門の名前が変わるとすれば、2000年12月31日の23時59分59秒においては、前の部門の名前が有効であるべきです。DurationLimited.current_dateが常にDate型であると仮定すればどちらの方法でも結果は同じですが、条件式を修正する方が厳密です。ただし、「終了日」を人に向けて画面表示する際には前日の日付に直す必要があります。(2012/03/28)
続いて、app/models/department.rbを次のように修正します。
class Department < ActiveRecord::Base include DurationLimited end
するとテストが通ります:
rake spec (省略) 1 example, 0 failures
望んだ仕様通りのfindメソッドができました。次回は、部門と製品間の関連づけについて書く予定です。
