/activerecord-bitemporal

BiTemporal Data Model for ActiveRecord

Primary LanguageRubyOtherNOASSERTION

ActiveRecord::Bitemporal

License gem-version gem-download CircleCI

Installation

Add this line to your application's Gemfile:

gem 'activerecord-bitemporal'

And then execute:

$ bundle

Or install it yourself as:

$ gem install activerecord-bitemporal

概要

activerecord-bitemporal は Rails の ActiveRecord で Bitemporal Data Model を扱うためのライブラリになります。 activerecord-bitemporal では、モデルを生成すると

employee = nil
# MEMO: データをわかりやすくする為に時間を固定
#       2019/1/10 にレコードを生成する
Timecop.freeze("2019/1/10") {
  employee = Employee.create(emp_code: "001", name: "Jane")
}

以下のようなレコードが生成されます。

id bitemporal_id emp_code name valid_from valid_to transaction_from transaction_to
1 1 001 Jane 2019-01-10 9999-12-31 2019-01-10 9999-12-31

そのモデルに対して更新を行うと

employee = nil
Timecop.freeze("2019/1/10") {
  employee = Employee.create(emp_code: "001", name: "Jane")
}

Timecop.freeze("2019/1/15") {
  # 更新する
  employee.update(name: "Tom")
}

次のような履歴レコードが暗黙的に生成されます。

id bitemporal_id emp_code name valid_from valid_to transaction_from transaction_to
1 1 001 Jane 2019-01-10 9999-12-31 2019-01-10 2019-01-15
2 1 001 Jane 2019-01-10 2019-01-15 2019-01-15 9999-12-31
3 1 001 Tom 2019-01-15 9999-12-31 2019-01-15 9999-12-31

更に更新すると

employee = nil
Timecop.freeze("2019/1/10") {
  employee = Employee.create(emp_code: "001", name: "Jane")
}

Timecop.freeze("2019/1/15") {
  employee.update(name: "Tom")
}

Timecop.freeze("2019/1/20") {
  # 更に更新
  employee.update(name: "Kevin")
}

更新する度にどんどん履歴レコードが増えていきます。

id bitemporal_id emp_code name valid_from valid_to transaction_from transaction_to
1 1 001 Jane 2019-01-10 9999-12-31 2019-01-10 2019-01-15
2 1 001 Jane 2019-01-10 2019-01-15 2019-01-15 9999-12-31
3 1 001 Tom 2019-01-15 9999-12-31 2019-01-15 2019-01-20
4 1 001 Tom 2019-01-15 2019-01-20 2019-01-20 9999-12-31
5 1 001 Kevin 2019-01-20 9999-12-31 2019-01-20 9999-12-31

また、レコードを読み込む場合は暗黙的に『一番最新のレコード』を参照します。

employee = nil
Timecop.freeze("2019/1/10") {
  employee = Employee.create(emp_code: "001", name: "Jane")
}

Timecop.freeze("2019/1/15") {
  employee.update(name: "Tom")
}

Timecop.freeze("2019/1/20") {
  employee.update(name: "Kevin")
}

Timecop.freeze("2019/1/25") {
  # 現時点で有効なレコードのみを参照する
  pp Employee.count
  # => 1

  # name = "Tom" は過去の履歴レコードとして扱われるので参照されない
  pp Employee.find_by(name: "Tom")
  # => nil

  # 最新のみ参照する
  pp Employee.all
  # [#<Employee:0x0000559b1b37eb08
  #   id: 1,
  #   bitemporal_id: 1,
  #   emp_code: "001",
  #   name: "Kevin",
  #   valid_from: 2019-01-20,
  #   valid_to: 9999-12-31,
  #   transaction_from: 2019-01-20,
  #   transaction_to: 9999-12-31>]
}

任意の時間の履歴レコードを参照したい場合は find_at_time(datetime, id) で時間指定して取得する事が出来ます。

employee = nil
Timecop.freeze("2019/1/10") {
  employee = Employee.create(emp_code: "001", name: "Jane")
}

Timecop.freeze("2019/1/15") {
  employee.update(name: "Tom")
}

Timecop.freeze("2019/1/20") {
  employee.update(name: "Kevin")
}

# 2019/1/25 に固定
Timecop.freeze("2019/1/25") {
  # 任意の時間の履歴レコードを取得する
  pp Employee.find_at_time("2019/1/13", employee.id).name
  # => "Jane"
  pp Employee.find_at_time("2019/1/18", employee.id).name
  # => "Tom"
  pp Employee.find_at_time("2019/1/23", employee.id).name
  # => "Kevin"
}

このように activerecord-bitemporal は、

  • 保存時に履歴レコードを自動生成
  • .find_at_time 等で任意の時間のレコードを取得する

というような事を行うライブラリになります。

モデルを BiTemporal Data Model 化する

任意のモデルを BiTemporal Data Model(以下、BTDM)として扱う場合は、以下のカラムを DB に追加する必要があります。

ActiveRecord::Schema.define(version: 1) do
  create_table :employees, force: true do |t|
    t.string :emp_code
    t.string :name

    # BTDM に必要なカラムを追加する
    t.integer :bitemporal_id
    t.datetime :valid_from
    t.datetime :valid_to
    t.datetime :transaction_from
    t.datetime :transaction_to
  end
end

それぞれのカラムは以下のような意味を持ちます。

カラム名
bitemporal_id id と同じ型 BTDM が共通で持つ id
valid_from datetime 有効時間の開始時刻
valid_to datetime 有効時間の終了時刻
transaction_from datetime システム時間の開始時刻
transaction_to datetime システム時間の終了時刻

また、モデルクラスでは ActiveRecord::Bitemporalinclude をする必要があります。

class Employee < ActiveRecord::Base
  include ActiveRecord::Bitemporal
end

これで Employee モデルを BTDM として扱うことが出来ます。 このドキュメントではこのモデルをサンプルとしてコードを書いていきます。

モデルインスタンスに対する操作について

ここではモデルの生成・更新・削除といったインスタンスに対する操作に関して解説します。

生成

以下のように BTDM を生成した場合、

# MEMO: Timecop を使って擬似的に 2019/1/10 の日付でレコードを生成
#       データをわかりやすくする為に使用しているだけで activerecord-bitemporal には Timecop は必要ありません
employee = nil
Timecop.freeze("2019/1/10") {
  employee = Employee.create(emp_code: "001", name: "Jane")
}

以下のようなレコードが生成されます。

id bitemporal_id emp_code name valid_from valid_to transaction_from transaction_to
1 1 001 Jane 2019-01-10 9999-12-31 2019-01-10 9999-12-31

この時に生成されるレコードのカラムには暗黙的に以下のような値が保存されます。

カラム
bitemporal_id 自身の id
valid_from 生成した時間
valid_to 擬似的な INFINITY 時間

これは『valid_from から valid_to までの期間で有効なデータ』という意味になります。 また、 valid_fromvalid_to を指定すれば『任意の時間』の履歴データも生成も出来ます。

Timecop.freeze("2019/1/10") {
  # 現時点よりも前からのデータを生成する
  Employee.create(emp_code: "001", name: "Jane", valid_from: "2019/1/1")
}

更新

#update 等でモデルを更新すると『更新時間』を基準とした履歴レコードが暗黙的に生成されます。

employee = nil
Timecop.freeze("2019/1/10") {
  employee = Employee.create(emp_code: "001", name: "Jane")
}

Timecop.freeze("2019/1/20") {
  # モデルを更新すると履歴レコードが生成される
  employee.update(name: "Tom")
  # これは #save でも同様に行われる
  # employee.name = "Tom"
  # employee.save
}

上記の操作を行うと以下のようなレコードが生成されます。

id bitemporal_id emp_code name valid_from valid_to transaction_from transaction_to
1 1 001 Jane 2019-01-10 9999-12-31 2019-01-10 2019-01-20
2 1 001 Jane 2019-01-10 2019-01-20 2019-01-20 9999-12-31
3 1 001 Tom 2019-01-20 9999-12-31 2019-01-20 9999-12-31

更新時には以下のような処理を行っており、結果的に新しいレコードが2つ生成されることになります。 また、この時に生成されるレコードは共通の bitemporal_id を保持します。

  1. 更新対象のレコード(id = 1)のシステム時間の終了時刻を更新する
  2. 更新を行った時間までのレコード(id = 2)を新しく生成する
  3. 更新を行った時間からのレコード(id = 3)を新しく生成する

activerecord-bitemporal ではレコードの内容を変更する際にレコードを直接変更するのではなくて『既存のレコードはシステム時間では参照しないような時刻』にして『変更後のレコードを新しく生成』していきます。 ただし、#update_columns で更新を行うと強制的にレコードが上書きされるので注意してください。

employee = nil
Timecop.freeze("2019/1/10") {
  employee = Employee.create(emp_code: "001", name: "Jane")
}

Timecop.freeze("2019/1/20") {
  # #update_columns で更新するとレコードが直接変更される
  employee.update_columns(name: "Tom")
}

上記の場合は以下のようなレコードになります。 id = 1 のレコードが直接変更されるので注意してください。

id bitemporal_id emp_code name valid_from valid_to transaction_from transaction_to
1 1 001 Tom 2019-01-10 9999-12-31 2019-01-10 9999-12-31

履歴を生成せずに上書きして更新したいのであれば activerecord-bitemporal 側で用意している #force_update を利用する事が出来ます。

employee = nil
Timecop.freeze("2019/1/10") {
  employee = Employee.create(emp_code: "001", name: "Jane")
}

Timecop.freeze("2019/1/20") {
  # #force_update のでは自身を受け取る
  # このブロック内であれば履歴を生成せずにレコードの変更が行われる
  employee.force_update { |employee|
    employee.update(name: "Tom")
  }
}

上記の場合は以下のレコードが生成されます。

id bitemporal_id emp_code name valid_from valid_to transaction_from transaction_to
1 1 001 Jane 2019-01-10 9999-12-31 2019-01-10 2019-01-20
2 1 001 Tom 2019-01-10 9999-12-31 2019-01-20 9999-12-31

この場合は id = 1 はシステムの終了時刻が更新され、新しい id = 2 のレコードが生成されます。

更新時間を指定して更新

TODO:

削除

更新と同様にレコードのシステム時間の終了時刻を更新しつつ、新しいレコードが生成されます。

employee = nil
Timecop.freeze("2019/1/10") {
  employee = Employee.create(emp_code: "001", name: "Jane")
}

Timecop.freeze("2019/1/20") {
  employee.update(name: "Tom")
}

Timecop.freeze("2019/1/30") {
  # 削除を行うとその時間までの履歴が生成される
  employee.destroy
}

上記の場合では以下のようなレコードが生成されます。

id bitemporal_id emp_code name valid_from valid_to transaction_from transaction_to
1 1 001 Jane 2019-01-10 9999-12-31 2019-01-10 2019-01-20
2 1 001 Jane 2019-01-10 2019-01-20 2019-01-20 9999-12-31
3 1 001 Tom 2019-01-20 9999-12-31 2019-01-20 2019-01-30
4 1 001 Tom 2019-01-20 2019-01-30 2019-01-30 9999-12-31

削除も更新と同様に

  1. 削除対象のレコード(id = 3)のシステム時間の終了時刻を更新する
  2. 削除を行った時間までの履歴レコード(id = 4)を新しく生成する

という風に『システム時間の終了時刻を更新してから新しいレコードを生成する』という処理を行っています。

ユニーク制約

BTDM では『履歴の時間が被っている場合』にユニーク制約のバリデーションを行います。

Employee.create!(name: "Jane", valid_from: "2019/1/1", valid_to: "2019/1/10")

# OK : 同じ時間帯で被っていない
Employee.create!(name: "Jane", valid_from: "2019/2/1", valid_to: "2019/2/10")

# NG : 同じ時間帯で被っている
Employee.create!(name: "Jane", valid_from: "2019/2/5", valid_to: "2019/2/15")

# OK : valid_from と valid_to は同じでも問題ない
Employee.create!(name: "Jane", valid_from: "2019/2/10", valid_to: "2019/2/20")

また、 BTDM の bitemporal_id もユニーク制約となっているので注意してください。

検索について

BTDM のレコードの検索について解説します。

検索時にデフォルトで追加されるクエリ

BTDM では DB からレコードを参照する場合、暗黙的に

  • 現在の時間を指定する時間指定クエリ
  • 論理削除を除くクエリ

が追加された状態で SQL 文が構築されます。

Timecop.freeze("2019/1/20") {
  # 現在の時間の履歴を返すために暗黙的に時間指定や論理削除されたレコードが除かれる
  puts Employee.all.to_sql
  # => SELECT "employees".* FROM "employees" WHERE "employees"."valid_from" <= '2019-01-20 00:00:00' AND "employees"."valid_to" > '2019-01-20 00:00:00' AND "employees"."transaction_from" <= '2019-01-20 00:00:00' AND "employees"."transaction_to" > '2019-01-20 00:00:00'
}

これにより DB 上に複数の履歴レコードや論理削除されているレコードがあっても『現時点で有効な』レコードが参照されます。

employee = nil
Timecop.freeze("2019/1/10") {
  employee = Employee.create(name: "Jane")
}

Timecop.freeze("2019/1/15") {
  employee.update(name: "Tom")
}

Timecop.freeze("2019/1/20") {
  # DB 上では履歴レコードや論理削除済みレコードなどが複数存在するが、暗黙的にクエリが追加されているので
  # 通常の ActiveRecord のモデルを操作した時と同じレコードを返す
  pp Employee.count
  # => 1

  pp Employee.first
  # => #<Employee:0x000055efd894e9e0
  #     id: 1,
  #     bitemporal_id: 1,
  #     emp_code: nil,
  #     name: "Tom",
  #     valid_from: 2019-01-15,
  #     valid_to: 9999-12-31,
  #     transaction_from: 2019-01-15,
  #     transaction_to: 9999-12-31>

  # 更新前の名前で検索しても引っかからない
  pp Employee.where(name: "Jane").first
  # => nil

  # なぜなら暗黙的に時間指定のクエリが追加されている為
  puts Employee.where(name: "Jane").to_sql
  # => SELECT "employees".* FROM "employees" WHERE "employees"."valid_from" <= '2019-01-20 00:00:00' AND "employees"."valid_to" > '2019-01-20 00:00:00' AND "employees"."transaction_from" <= '2019-01-20 00:00:00' AND "employees"."transaction_to" > '2019-01-20 00:00:00' AND "employees"."name" = 'Jane'
}

このように『現在の時間で有効なレコード』のみが検索の対象となります。 また、これは default_scope ではなくて BTDM が独自にハックして暗黙的に追加する仕組みを実装しているので .unscoped で取り除く事は出来ないので注意してください。

# default_scope であれば unscoped で無効化することが出来るが、BTDM のデフォルトクエリはそのまま
puts Employee.unscoped { Employee.all.to_sql }
# => SELECT "employees".* FROM "employees" WHERE "employees"."valid_from" <= '2019-10-25 07:56:06.731259' AND "employees"."valid_to" > '2019-10-25 07:56:06.731259' AND "employees"."transaction_from" <= '2019-10-25 07:56:06.731259' AND "employees"."transaction_to" > '2019-10-25 07:56:06.731259'

検索時にデフォルトクエリを取り除く

検索時にデフォルトクエリを取り除きたい場合、以下のスコープを使用します。

スコープ 動作
.ignore_valid_datetime 時間指定を無視する
.ignore_transaction_datetime 論理削除されているレコードを含める
.ignore_bitemporal_datetime 全てのレコードを対象とする
Timecop.freeze("2019/1/20") {
  # 時間指定をしているクエリを取り除く
  puts Employee.ignore_valid_datetime.to_sql
  # => SELECT "employees".* FROM "employees" WHERE "employees"."transaction_from" <= '2019-01-20 00:00:00' AND "employees"."transaction_to" > '2019-01-20 00:00:00'

  # 論理削除しているレコードも含める
  puts Employee.ignore_transaction_datetime.to_sql
  # => SELECT "employees".* FROM "employees" WHERE "employees"."valid_from" <= '2019-01-20 00:00:00' AND "employees"."valid_to" > '2019-01-20 00:00:00'

  # 全てのレコードを対象とする
  puts Employee.ignore_bitemporal_datetime.to_sql
  # => SELECT "employees".* FROM "employees"
}

『任意のレコードの履歴一覧を取得する』ようなことを行う場合は ignore_valid_datetime を使用して全レコードを参照するようにします。

employee = nil
Timecop.freeze("2019/1/10") {
  employee = Employee.create(name: "Jane")
}

Timecop.freeze("2019/1/15") {
  employee.update(name: "Tom")
}

Timecop.freeze("2019/1/20") {
  employee.update(name: "Kevin")

  # NOTE: bitemporal_id を参照することで同一の履歴を取得する事が出来る
  pp Employee.ignore_valid_datetime.where(bitemporal_id: employee.bitemporal_id).map(&:name)
  # => ["Jane", "Tom", "Kevin"]
}

時間を指定して検索する

任意の時間を指定して検索を行いたい場合、.valid_at(datetime) を利用する事が出来ます。

employee1 = nil
employee2 = nil
Timecop.freeze("2019/1/10") {
  employee1 = Employee.create(emp_code: "001", name: "Jane")
}

Timecop.freeze("2019/1/15") {
  employee1.update(name: "Tom")
  employee2 = Employee.create(emp_code: "002", name: "Homu")
}

Timecop.freeze("2019/1/20") {
  # valid_at で任意の時間を参照して検索する事が出来る
  puts Employee.valid_at("2019/1/10").to_sql
  # => SELECT "employees".* FROM "employees" WHERE "employees"."valid_from" <= '2019-01-10 00:00:00' AND "employees"."valid_to" > '2019-01-10 00:00:00' AND "employees"."transaction_from" <= '2019-01-20 00:00:00' AND "employees"."transaction_to" > '2019-01-20 00:00:00'

  pp Employee.valid_at("2019/1/10").map(&:name)
  # => ["Jane"]
  pp Employee.valid_at("2019/1/17").map(&:name)
  # => ["Tom", "Homu"]

  # そのまま続けてリレーション出来る
  pp Employee.valid_at("2019/1/17").where(name: "Tom").first
  # => #<Employee:0x000055678afd1d20
  #     id: 1,
  #     bitemporal_id: 1,
  #     emp_code: "001",
  #     name: "Tom",
  #     valid_from: 2019-01-15,
  #     valid_to: 9999-12-31,
  #     transaction_from: 2019-01-15,
  #     transaction_to: 9999-12-31>
}

また、特定の id で検索するのであれば .find_at_time(datetime, id) も利用できます。

employee1 = nil
employee2 = nil
Timecop.freeze("2019/1/10") {
  employee1 = Employee.create(emp_code: "001", name: "Jane")
}

Timecop.freeze("2019/1/15") {
  employee1.update(name: "Tom")
  employee2 = Employee.create(emp_code: "002", name: "Homu")
}

Timecop.freeze("2019/1/20") {
  # 任意の時間の id のレコードを返す
  pp Employee.find_at_time("2019/1/12", employee1.id)
  # => #<Employee:0x000055b776d7ff18
  #     id: 1,
  #     bitemporal_id: 1,
  #     emp_code: "001",
  #     name: "Jane",
  #     valid_from: 2019-01-10,
  #     valid_to: 2019-01-15,
  #     transaction_from: 2019-01-15,
  #     transaction_to: 9999-12-31>

  # 見つからなければ nil を返す
  pp Employee.find_at_time("2019/1/12", employee2.id)
  # => nil

  # find_at_time の場合は例外を返す
  pp Employee.find_at_time!("2019/1/12", employee2.id)
  # => raise ActiveRecord::RecordNotFound (ActiveRecord::RecordNotFound)
}

idbitemporal_id について

BTDM のインスタンスの id は特殊で『レコードの id』ではなくて『bitemporal_id の値』が割り当てられています。

employee = nil
Timecop.freeze("2019/1/10") {
  employee = Employee.create(emp_code: "001", name: "Jane")
}

Timecop.freeze("2019/1/15") {
  employee.update(name: "Tom")
}

Timecop.freeze("2019/1/20") {
  employee.update(name: "Kevin")

  # 現在のレコードの id は 1 を返す
  pp Employee.first.id
  # => 1

  # 別の履歴レコードを参照しても id は同じ
  pp Employee.find_at_time("2019/1/12", employee.id).id
  # => 1
}

インスタンスの id はレコードの読み込み時に自動的に設定されています。 これは Employee.find(employee.id) で検索を行う際に id の値が レコードの id ではなくて bitemporal_id のほうが実装上都合がいい、という由来になっています。 この影響により Employee.pluck(:id)Employee.map(&:id)Employee.ids が返す結果が微妙に異なるので注意してください。

employee = nil
Timecop.freeze("2019/1/10") {
  employee = Employee.create(emp_code: "001", name: "Jane")
}

Timecop.freeze("2019/1/15") {
  employee.update(name: "Tom")
}

Timecop.freeze("2019/1/20") {
  employee.update(name: "Kevin")

  # DB の生 id が返ってくる
  pp Employee.ignore_valid_datetime.pluck(:id)

  # bitemporal_id が返ってくる
  pp Employee.ignore_valid_datetime.map(&:id)

  # bitemporal_id が返ってくる
  pp Employee.ignore_valid_datetime.ids
}

レコードの内容

id bitemporal_id emp_code name valid_from valid_to transaction_from transaction_to
1 1 001 Jane 2019-01-10 9999-12-31 2019-01-10 2019-01-15
2 1 001 Jane 2019-01-10 2019-01-15 2019-01-15 9999-12-31
3 1 001 Tom 2019-01-15 9999-12-31 2019-01-15 2019-01-20
4 1 001 Tom 2019-01-15 2019-01-20 2019-01-20 9999-12-31
5 1 001 Kevin 2019-01-20 9999-12-31 2019-01-20 9999-12-31

また、元々の DB の id#swapped_id で参照する事が出来ます。

employee = nil
Timecop.freeze("2019/1/10") {
  employee = Employee.create(emp_code: "001", name: "Jane")
}

Timecop.freeze("2019/1/15") {
  employee.update(name: "Tom")
}

Timecop.freeze("2019/1/20") {
  employee.update(name: "Kevin")

  pp Employee.first.swapped_id
  # => 5
  pp Employee.find_at_time("2019/1/12", employee.id).swapped_id
  # => 2
}

まとめると BTDM のインスタンスは以下のような値を保持しています。

  • id : bitemporal_id が暗黙的に設定される
  • bitemporal_id : BTDM 共通の id
  • swapped_id : DB の生 id

id 検索の注意点

BTDM では find_by(id: xxx)where(id: xxx) を行う場合 id ではなくて bitemporal_id を参照する必要があります。

# NG : BTDM の場合は id 検索出来ない
Employee.find_by(id: employee.id)

# OK : bitemporal_id で検索を行う
# MEMO: id = bitemporal_id なので
#       find_by(bitemporal_id: employee.id)
#       でも動作するが employee.bitemporal_id と書いたほうが意図が伝わりやすい
Employee.find_by(bitemporal_id: employee.bitemporal_id)

# NG : BTDM の場合は id 検索出来ない
Employee.where(id: employee.id)

# OK : bitemporal_id で検索を行う
Employee.where(bitemporal_id: employee.bitemporal_id)

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and tags, and push the .gem file to rubygems.org.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/kufu/activerecord-bitemporal.

Copyright

See ./LICENSE