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
メソッドができました。次回は、部門と製品間の関連づけについて書く予定です。