ActiveRecord::Bitemporal
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::Bitemporal
を include
をする必要があります。
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_from
や valid_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
を保持します。
- 更新対象のレコード(
id = 1
)のシステム時間の終了時刻を更新する - 更新を行った時間までのレコード(
id = 2
)を新しく生成する - 更新を行った時間からのレコード(
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 |
削除も更新と同様に
- 削除対象のレコード(
id = 3
)のシステム時間の終了時刻を更新する - 削除を行った時間までの履歴レコード(
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)
}
id
と bitemporal_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