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