activerecord-bitemporal 0.0.1 → 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.circleci/config.yml +123 -0
- data/.github/auto_assign.yml +27 -0
- data/.gitignore +2 -8
- data/Appraisals +21 -0
- data/CHANGELOG.md +39 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +5 -2
- data/Gemfile.lock +62 -2
- data/LICENSE +202 -0
- data/README.md +724 -0
- data/Rakefile +6 -0
- data/activerecord-bitemporal.gemspec +20 -18
- data/bin/console +1 -0
- data/docker-compose.yml +11 -0
- data/gemfiles/rails_5.2.gemfile +8 -0
- data/gemfiles/rails_6.0.gemfile +8 -0
- data/gemfiles/rails_6.1.gemfile +8 -0
- data/gemfiles/rails_7.0.gemfile +8 -0
- data/gemfiles/rails_main.gemfile +8 -0
- data/lib/activerecord-bitemporal/bitemporal.rb +588 -0
- data/lib/activerecord-bitemporal/patches.rb +130 -0
- data/lib/activerecord-bitemporal/scope.rb +501 -0
- data/lib/activerecord-bitemporal/version.rb +4 -2
- data/lib/activerecord-bitemporal.rb +177 -4
- metadata +156 -15
data/README.md
ADDED
@@ -0,0 +1,724 @@
|
|
1
|
+
ActiveRecord::Bitemporal
|
2
|
+
========================
|
3
|
+
|
4
|
+
[![License](https://img.shields.io/github/license/kufu/activerecord-bitemporal.svg?color=blue)](https://github.com/kufu/activerecord-bitemporal/blob/master/LICENSE)
|
5
|
+
[![gem-version](https://img.shields.io/gem/v/activerecord-bitemporal.svg)](https://rubygems.org/gems/activerecord-bitemporal)
|
6
|
+
[![gem-download](https://img.shields.io/gem/dt/activerecord-bitemporal.svg)](https://rubygems.org/gems/activerecord-bitemporal)
|
7
|
+
[![CircleCI](https://circleci.com/gh/kufu/activerecord-bitemporal.svg?style=svg)](https://circleci.com/gh/kufu/activerecord-bitemporal)
|
8
|
+
|
9
|
+
## Installation
|
10
|
+
|
11
|
+
Add this line to your application's Gemfile:
|
12
|
+
|
13
|
+
```ruby
|
14
|
+
gem 'activerecord-bitemporal'
|
15
|
+
```
|
16
|
+
|
17
|
+
And then execute:
|
18
|
+
|
19
|
+
$ bundle
|
20
|
+
|
21
|
+
Or install it yourself as:
|
22
|
+
|
23
|
+
$ gem install activerecord-bitemporal
|
24
|
+
|
25
|
+
## 概要
|
26
|
+
|
27
|
+
activerecord-bitemporal は Rails の ActiveRecord で Bitemporal Data Model を扱うためのライブラリになります。
|
28
|
+
activerecord-bitemporal では、モデルを生成すると
|
29
|
+
|
30
|
+
```ruby
|
31
|
+
employee = nil
|
32
|
+
# MEMO: データをわかりやすくする為に時間を固定
|
33
|
+
# 2019/1/10 にレコードを生成する
|
34
|
+
Timecop.freeze("2019/1/10") {
|
35
|
+
employee = Employee.create(emp_code: "001", name: "Jane")
|
36
|
+
}
|
37
|
+
```
|
38
|
+
|
39
|
+
以下のようなレコードが生成されます。
|
40
|
+
|
41
|
+
| id | bitemporal_id | emp_code | name | valid_from | valid_to | transaction_from | transaction_to |
|
42
|
+
| --- | --- | --- | --- | --- | --- | --- | --- |
|
43
|
+
| 1 | 1 | 001 | Jane | 2019-01-10 | 9999-12-31 | 2019-01-10 | 9999-12-31 |
|
44
|
+
|
45
|
+
そのモデルに対して更新を行うと
|
46
|
+
|
47
|
+
```ruby
|
48
|
+
employee = nil
|
49
|
+
Timecop.freeze("2019/1/10") {
|
50
|
+
employee = Employee.create(emp_code: "001", name: "Jane")
|
51
|
+
}
|
52
|
+
|
53
|
+
Timecop.freeze("2019/1/15") {
|
54
|
+
# 更新する
|
55
|
+
employee.update(name: "Tom")
|
56
|
+
}
|
57
|
+
```
|
58
|
+
|
59
|
+
次のような履歴レコードが暗黙的に生成されます。
|
60
|
+
|
61
|
+
| id | bitemporal_id | emp_code | name | valid_from | valid_to | transaction_from | transaction_to |
|
62
|
+
| --- | --- | --- | --- | --- | --- | --- | --- |
|
63
|
+
| 1 | 1 | 001 | Jane | 2019-01-10 | 9999-12-31 | 2019-01-10 | 2019-01-15 |
|
64
|
+
| 2 | 1 | 001 | Jane | 2019-01-10 | 2019-01-15 | 2019-01-15 | 9999-12-31 |
|
65
|
+
| 3 | 1 | 001 | Tom | 2019-01-15 | 9999-12-31 | 2019-01-15 | 9999-12-31 |
|
66
|
+
|
67
|
+
更に更新すると
|
68
|
+
|
69
|
+
```ruby
|
70
|
+
employee = nil
|
71
|
+
Timecop.freeze("2019/1/10") {
|
72
|
+
employee = Employee.create(emp_code: "001", name: "Jane")
|
73
|
+
}
|
74
|
+
|
75
|
+
Timecop.freeze("2019/1/15") {
|
76
|
+
employee.update(name: "Tom")
|
77
|
+
}
|
78
|
+
|
79
|
+
Timecop.freeze("2019/1/20") {
|
80
|
+
# 更に更新
|
81
|
+
employee.update(name: "Kevin")
|
82
|
+
}
|
83
|
+
```
|
84
|
+
|
85
|
+
更新する度にどんどん履歴レコードが増えていきます。
|
86
|
+
|
87
|
+
| id | bitemporal_id | emp_code | name | valid_from | valid_to | transaction_from | transaction_to |
|
88
|
+
| --- | --- | --- | --- | --- | --- | --- | --- |
|
89
|
+
| 1 | 1 | 001 | Jane | 2019-01-10 | 9999-12-31 | 2019-01-10 | 2019-01-15 |
|
90
|
+
| 2 | 1 | 001 | Jane | 2019-01-10 | 2019-01-15 | 2019-01-15 | 9999-12-31 |
|
91
|
+
| 3 | 1 | 001 | Tom | 2019-01-15 | 9999-12-31 | 2019-01-15 | 2019-01-20 |
|
92
|
+
| 4 | 1 | 001 | Tom | 2019-01-15 | 2019-01-20 | 2019-01-20 | 9999-12-31 |
|
93
|
+
| 5 | 1 | 001 | Kevin | 2019-01-20 | 9999-12-31 | 2019-01-20 | 9999-12-31 |
|
94
|
+
|
95
|
+
また、レコードを読み込む場合は暗黙的に『一番最新のレコード』を参照します。
|
96
|
+
|
97
|
+
```ruby
|
98
|
+
employee = nil
|
99
|
+
Timecop.freeze("2019/1/10") {
|
100
|
+
employee = Employee.create(emp_code: "001", name: "Jane")
|
101
|
+
}
|
102
|
+
|
103
|
+
Timecop.freeze("2019/1/15") {
|
104
|
+
employee.update(name: "Tom")
|
105
|
+
}
|
106
|
+
|
107
|
+
Timecop.freeze("2019/1/20") {
|
108
|
+
employee.update(name: "Kevin")
|
109
|
+
}
|
110
|
+
|
111
|
+
Timecop.freeze("2019/1/25") {
|
112
|
+
# 現時点で有効なレコードのみを参照する
|
113
|
+
pp Employee.count
|
114
|
+
# => 1
|
115
|
+
|
116
|
+
# name = "Tom" は過去の履歴レコードとして扱われるので参照されない
|
117
|
+
pp Employee.find_by(name: "Tom")
|
118
|
+
# => nil
|
119
|
+
|
120
|
+
# 最新のみ参照する
|
121
|
+
pp Employee.all
|
122
|
+
# [#<Employee:0x0000559b1b37eb08
|
123
|
+
# id: 1,
|
124
|
+
# bitemporal_id: 1,
|
125
|
+
# emp_code: "001",
|
126
|
+
# name: "Kevin",
|
127
|
+
# valid_from: 2019-01-20,
|
128
|
+
# valid_to: 9999-12-31,
|
129
|
+
# transaction_from: 2019-01-20,
|
130
|
+
# transaction_to: 9999-12-31>]
|
131
|
+
}
|
132
|
+
```
|
133
|
+
|
134
|
+
任意の時間の履歴レコードを参照したい場合は `find_at_time(datetime, id)` で時間指定して取得する事が出来ます。
|
135
|
+
|
136
|
+
```ruby
|
137
|
+
employee = nil
|
138
|
+
Timecop.freeze("2019/1/10") {
|
139
|
+
employee = Employee.create(emp_code: "001", name: "Jane")
|
140
|
+
}
|
141
|
+
|
142
|
+
Timecop.freeze("2019/1/15") {
|
143
|
+
employee.update(name: "Tom")
|
144
|
+
}
|
145
|
+
|
146
|
+
Timecop.freeze("2019/1/20") {
|
147
|
+
employee.update(name: "Kevin")
|
148
|
+
}
|
149
|
+
|
150
|
+
# 2019/1/25 に固定
|
151
|
+
Timecop.freeze("2019/1/25") {
|
152
|
+
# 任意の時間の履歴レコードを取得する
|
153
|
+
pp Employee.find_at_time("2019/1/13", employee.id).name
|
154
|
+
# => "Jane"
|
155
|
+
pp Employee.find_at_time("2019/1/18", employee.id).name
|
156
|
+
# => "Tom"
|
157
|
+
pp Employee.find_at_time("2019/1/23", employee.id).name
|
158
|
+
# => "Kevin"
|
159
|
+
}
|
160
|
+
```
|
161
|
+
|
162
|
+
このように activerecord-bitemporal は、
|
163
|
+
|
164
|
+
* 保存時に履歴レコードを自動生成
|
165
|
+
* `.find_at_time` 等で任意の時間のレコードを取得する
|
166
|
+
|
167
|
+
というような事を行うライブラリになります。
|
168
|
+
|
169
|
+
|
170
|
+
## モデルを BiTemporal Data Model 化する
|
171
|
+
|
172
|
+
任意のモデルを BiTemporal Data Model(以下、BTDM)として扱う場合は、以下のカラムを DB に追加する必要があります。
|
173
|
+
|
174
|
+
```ruby
|
175
|
+
ActiveRecord::Schema.define(version: 1) do
|
176
|
+
create_table :employees, force: true do |t|
|
177
|
+
t.string :emp_code
|
178
|
+
t.string :name
|
179
|
+
|
180
|
+
# BTDM に必要なカラムを追加する
|
181
|
+
t.integer :bitemporal_id
|
182
|
+
t.datetime :valid_from
|
183
|
+
t.datetime :valid_to
|
184
|
+
t.datetime :transaction_from
|
185
|
+
t.datetime :transaction_to
|
186
|
+
end
|
187
|
+
end
|
188
|
+
```
|
189
|
+
|
190
|
+
それぞれのカラムは以下のような意味を持ちます。
|
191
|
+
|
192
|
+
| カラム名 | 型 | 値 |
|
193
|
+
| --- | --- | --- |
|
194
|
+
| `bitemporal_id` | `id` と同じ型 | BTDM が共通で持つ `id` |
|
195
|
+
| `valid_from` | `datetime` | 有効時間の開始時刻 |
|
196
|
+
| `valid_to` | `datetime` | 有効時間の終了時刻 |
|
197
|
+
| `transaction_from` | `datetime` | システム時間の開始時刻 |
|
198
|
+
| `transaction_to` | `datetime` | システム時間の終了終了 |
|
199
|
+
|
200
|
+
また、モデルクラスでは `ActiveRecord::Bitemporal` を `include` をする必要があります。
|
201
|
+
|
202
|
+
```ruby
|
203
|
+
class Employee < ActiveRecord::Base
|
204
|
+
include ActiveRecord::Bitemporal
|
205
|
+
end
|
206
|
+
```
|
207
|
+
|
208
|
+
これで `Employee` モデルを BTDM として扱うことが出来ます。
|
209
|
+
このドキュメントではこのモデルをサンプルとしてコードを書いていきます。
|
210
|
+
|
211
|
+
|
212
|
+
## モデルインスタンスに対する操作について
|
213
|
+
|
214
|
+
ここではモデルの生成・更新・削除といったインスタンスに対する操作に関して解説します。
|
215
|
+
|
216
|
+
|
217
|
+
### 生成
|
218
|
+
|
219
|
+
以下のように BTDM を生成した場合、
|
220
|
+
|
221
|
+
```ruby
|
222
|
+
# MEMO: Timecop を使って擬似的に 2019/1/10 の日付でレコードを生成
|
223
|
+
# データをわかりやすくする為に使用しているだけで activerecord-bitemporal には Timecop は必要ありません
|
224
|
+
employee = nil
|
225
|
+
Timecop.freeze("2019/1/10") {
|
226
|
+
employee = Employee.create(emp_code: "001", name: "Jane")
|
227
|
+
}
|
228
|
+
```
|
229
|
+
|
230
|
+
以下のようなレコードが生成されます。
|
231
|
+
|
232
|
+
| id | bitemporal_id | emp_code | name | valid_from | valid_to | transaction_from | transaction_to |
|
233
|
+
| --- | --- | --- | --- | --- | --- | --- | --- |
|
234
|
+
| 1 | 1 | 001 | Jane | 2019-01-10 | 9999-12-31 | 2019-01-10 | 9999-12-31 |
|
235
|
+
|
236
|
+
この時に生成されるレコードのカラムには暗黙的に以下のような値が保存されます。
|
237
|
+
|
238
|
+
| カラム | 値 |
|
239
|
+
| --- | --- |
|
240
|
+
| `bitemporal_id` | 自身の `id` |
|
241
|
+
| `valid_from` | 生成した時間 |
|
242
|
+
| `valid_to` | 擬似的な `INFINITY` 時間 |
|
243
|
+
|
244
|
+
これは『`valid_from` から `valid_to` までの期間で有効なデータ』という意味になります。
|
245
|
+
また、 `valid_from` や `valid_to` を指定すれば『任意の時間』の履歴データも生成も出来ます。
|
246
|
+
|
247
|
+
```ruby
|
248
|
+
Timecop.freeze("2019/1/10") {
|
249
|
+
# 現時点よりも前からのデータを生成する
|
250
|
+
Employee.create(emp_code: "001", name: "Jane", valid_from: "2019/1/1")
|
251
|
+
}
|
252
|
+
```
|
253
|
+
|
254
|
+
|
255
|
+
### 更新
|
256
|
+
|
257
|
+
`#update` 等でモデルを更新すると『更新時間』を基準とした履歴レコードが暗黙的に生成されます。
|
258
|
+
|
259
|
+
```ruby
|
260
|
+
employee = nil
|
261
|
+
Timecop.freeze("2019/1/10") {
|
262
|
+
employee = Employee.create(emp_code: "001", name: "Jane")
|
263
|
+
}
|
264
|
+
|
265
|
+
Timecop.freeze("2019/1/20") {
|
266
|
+
# モデルを更新すると履歴レコードが生成される
|
267
|
+
employee.update(name: "Tom")
|
268
|
+
# これは #save でも同様に行われる
|
269
|
+
# employee.name = "Tom"
|
270
|
+
# employee.save
|
271
|
+
}
|
272
|
+
```
|
273
|
+
|
274
|
+
上記の操作を行うと以下のようなレコードが生成されます。
|
275
|
+
|
276
|
+
| id | bitemporal_id | emp_code | name | valid_from | valid_to | transaction_from | transaction_to |
|
277
|
+
| --- | --- | --- | --- | --- | --- | --- | --- |
|
278
|
+
| 1 | 1 | 001 | Jane | 2019-01-10 | 9999-12-31 | 2019-01-10 | 2019-01-20 |
|
279
|
+
| 2 | 1 | 001 | Jane | 2019-01-10 | 2019-01-20 | 2019-01-20 | 9999-12-31 |
|
280
|
+
| 3 | 1 | 001 | Tom | 2019-01-20 | 9999-12-31 | 2019-01-20 | 9999-12-31 |
|
281
|
+
|
282
|
+
更新時には以下のような処理を行っており、結果的に新しいレコードが2つ生成されることになります。
|
283
|
+
また、この時に生成されるレコードは共通の `bitemporal_id` を保持します。
|
284
|
+
|
285
|
+
1. 更新対象のレコード(`id = 1`)のシステム時間の終了時刻を更新する
|
286
|
+
2. 更新を行った時間までのレコード(`id = 2`)を新しく生成する
|
287
|
+
3. 更新を行った時間からのレコード(`id = 3`)を新しく生成する
|
288
|
+
|
289
|
+
activerecord-bitemporal ではレコードの内容を変更する際にレコードを直接変更するのではなくて『既存のレコードはシステム時間では参照しないような時刻』にして『変更後のレコードを新しく生成』していきます。
|
290
|
+
ただし、`#update_columns` で更新を行うと強制的にレコードが上書きされるので注意してください。
|
291
|
+
|
292
|
+
```ruby
|
293
|
+
employee = nil
|
294
|
+
Timecop.freeze("2019/1/10") {
|
295
|
+
employee = Employee.create(emp_code: "001", name: "Jane")
|
296
|
+
}
|
297
|
+
|
298
|
+
Timecop.freeze("2019/1/20") {
|
299
|
+
# #update_columns で更新するとレコードが直接変更される
|
300
|
+
employee.update_columns(name: "Tom")
|
301
|
+
}
|
302
|
+
```
|
303
|
+
|
304
|
+
上記の場合は以下のようなレコードになります。
|
305
|
+
`id = 1` のレコードが直接変更されるので注意してください。
|
306
|
+
|
307
|
+
| id | bitemporal_id | emp_code | name | valid_from | valid_to | transaction_from | transaction_to |
|
308
|
+
| --- | --- | --- | --- | --- | --- | --- | --- |
|
309
|
+
| 1 | 1 | 001 | Tom | 2019-01-10 | 9999-12-31 | 2019-01-10 | 9999-12-31 |
|
310
|
+
|
311
|
+
履歴を生成せずに上書きして更新したいのであれば activerecord-bitemporal 側で用意している `#force_update` を利用する事が出来ます。
|
312
|
+
|
313
|
+
```ruby
|
314
|
+
employee = nil
|
315
|
+
Timecop.freeze("2019/1/10") {
|
316
|
+
employee = Employee.create(emp_code: "001", name: "Jane")
|
317
|
+
}
|
318
|
+
|
319
|
+
Timecop.freeze("2019/1/20") {
|
320
|
+
# #force_update のでは自身を受け取る
|
321
|
+
# このブロック内であれば履歴を生成せずにレコードの変更が行われる
|
322
|
+
employee.force_update { |employee|
|
323
|
+
employee.update(name: "Tom")
|
324
|
+
}
|
325
|
+
}
|
326
|
+
```
|
327
|
+
|
328
|
+
上記の場合は以下のレコードが生成されます。
|
329
|
+
|
330
|
+
| id | bitemporal_id | emp_code | name | valid_from | valid_to | transaction_from | transaction_to |
|
331
|
+
| --- | --- | --- | --- | --- | --- | --- | --- |
|
332
|
+
| 1 | 1 | 001 | Jane | 2019-01-10 | 9999-12-31 | 2019-01-10 | 2019-01-20 |
|
333
|
+
| 2 | 1 | 001 | Tom | 2019-01-10 | 9999-12-31 | 2019-01-20 | 9999-12-31 |
|
334
|
+
|
335
|
+
この場合は `id = 1` はシステムの終了時刻が更新され、新しい `id = 2` のレコードが生成されます。
|
336
|
+
|
337
|
+
|
338
|
+
### 更新時間を指定して更新
|
339
|
+
|
340
|
+
TODO:
|
341
|
+
|
342
|
+
|
343
|
+
### 削除
|
344
|
+
|
345
|
+
更新と同様にレコードのシステム時間の終了時刻を更新しつつ、新しいレコードが生成されます。
|
346
|
+
|
347
|
+
```ruby
|
348
|
+
employee = nil
|
349
|
+
Timecop.freeze("2019/1/10") {
|
350
|
+
employee = Employee.create(emp_code: "001", name: "Jane")
|
351
|
+
}
|
352
|
+
|
353
|
+
Timecop.freeze("2019/1/20") {
|
354
|
+
employee.update(name: "Tom")
|
355
|
+
}
|
356
|
+
|
357
|
+
Timecop.freeze("2019/1/30") {
|
358
|
+
# 削除を行うとその時間までの履歴が生成される
|
359
|
+
employee.destroy
|
360
|
+
}
|
361
|
+
```
|
362
|
+
|
363
|
+
上記の場合では以下のようなレコードが生成されます。
|
364
|
+
|
365
|
+
| id | bitemporal_id | emp_code | name | valid_from | valid_to | transaction_from | transaction_to |
|
366
|
+
| --- | --- | --- | --- | --- | --- | --- | --- |
|
367
|
+
| 1 | 1 | 001 | Jane | 2019-01-10 | 9999-12-31 | 2019-01-10 | 2019-01-20 |
|
368
|
+
| 2 | 1 | 001 | Jane | 2019-01-10 | 2019-01-20 | 2019-01-20 | 9999-12-31 |
|
369
|
+
| 3 | 1 | 001 | Tom | 2019-01-20 | 9999-12-31 | 2019-01-20 | 2019-01-30 |
|
370
|
+
| 4 | 1 | 001 | Tom | 2019-01-20 | 2019-01-30 | 2019-01-30 | 9999-12-31 |
|
371
|
+
|
372
|
+
削除も更新と同様に
|
373
|
+
|
374
|
+
1. 削除対象のレコード(`id = 3`)のシステム時間の終了時刻を更新する
|
375
|
+
2. 削除を行った時間までの履歴レコード(`id = 4`)を新しく生成する
|
376
|
+
|
377
|
+
という風に『システム時間の終了時刻を更新してから新しいレコードを生成する』という処理を行っています。
|
378
|
+
|
379
|
+
|
380
|
+
### ユニーク制約
|
381
|
+
|
382
|
+
BTDM では『履歴の時間が被っている場合』にユニーク制約のバリデーションを行います。
|
383
|
+
|
384
|
+
```ruby
|
385
|
+
Employee.create!(name: "Jane", valid_from: "2019/1/1", valid_to: "2019/1/10")
|
386
|
+
|
387
|
+
# OK : 同じ時間帯で被っていない
|
388
|
+
Employee.create!(name: "Jane", valid_from: "2019/2/1", valid_to: "2019/2/10")
|
389
|
+
|
390
|
+
# NG : 同じ時間帯で被っている
|
391
|
+
Employee.create!(name: "Jane", valid_from: "2019/2/5", valid_to: "2019/2/15")
|
392
|
+
|
393
|
+
# OK : valid_from と valid_to は同じでも問題ない
|
394
|
+
Employee.create!(name: "Jane", valid_from: "2019/2/10", valid_to: "2019/2/20")
|
395
|
+
```
|
396
|
+
|
397
|
+
また、 BTDM の `bitemporal_id` もユニーク制約となっているので注意してください。
|
398
|
+
|
399
|
+
|
400
|
+
## 検索について
|
401
|
+
|
402
|
+
BTDM のレコードの検索について解説します。
|
403
|
+
|
404
|
+
|
405
|
+
### 検索時にデフォルトで追加されるクエリ
|
406
|
+
|
407
|
+
BTDM では DB からレコードを参照する場合、暗黙的に
|
408
|
+
|
409
|
+
* 現在の時間を指定する時間指定クエリ
|
410
|
+
* 論理削除を除くクエリ
|
411
|
+
|
412
|
+
が追加された状態で SQL 文が構築されます。
|
413
|
+
|
414
|
+
```ruby
|
415
|
+
Timecop.freeze("2019/1/20") {
|
416
|
+
# 現在の時間の履歴を返すために暗黙的に時間指定や論理削除されたレコードが除かれる
|
417
|
+
puts Employee.all.to_sql
|
418
|
+
# => 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_to" = '9999-12-31 00:00:00'
|
419
|
+
}
|
420
|
+
```
|
421
|
+
|
422
|
+
これにより DB 上に複数の履歴レコードや論理削除されているレコードがあっても『現時点で有効な』レコードが参照されます。
|
423
|
+
|
424
|
+
```ruby
|
425
|
+
employee = nil
|
426
|
+
Timecop.freeze("2019/1/10") {
|
427
|
+
employee = Employee.create(name: "Jane")
|
428
|
+
}
|
429
|
+
|
430
|
+
Timecop.freeze("2019/1/15") {
|
431
|
+
employee.update(name: "Tom")
|
432
|
+
}
|
433
|
+
|
434
|
+
Timecop.freeze("2019/1/20") {
|
435
|
+
# DB 上では履歴レコードや論理削除済みレコードなどが複数存在するが、暗黙的にクエリが追加されているので
|
436
|
+
# 通常の ActiveRecord のモデルを操作した時と同じレコードを返す
|
437
|
+
pp Employee.count
|
438
|
+
# => 1
|
439
|
+
|
440
|
+
pp Employee.first
|
441
|
+
# => #<Employee:0x000055efd894e9e0
|
442
|
+
# id: 1,
|
443
|
+
# bitemporal_id: 1,
|
444
|
+
# emp_code: nil,
|
445
|
+
# name: "Tom",
|
446
|
+
# valid_from: 2019-01-15,
|
447
|
+
# valid_to: 9999-12-31,
|
448
|
+
# transaction_from: 2019-01-15,
|
449
|
+
# transaction_to: 9999-12-31>
|
450
|
+
|
451
|
+
# 更新前の名前で検索しても引っかからない
|
452
|
+
pp Employee.where(name: "Jane").first
|
453
|
+
# => nil
|
454
|
+
|
455
|
+
# なぜなら暗黙的に時間指定のクエリが追加されている為
|
456
|
+
puts Employee.where(name: "Jane").to_sql
|
457
|
+
# => 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_to" = '9999-12-31 00:00:00' AND "employees"."name" = 'Jane'
|
458
|
+
}
|
459
|
+
```
|
460
|
+
|
461
|
+
このように『現在の時間で有効なレコード』のみが検索の対象となります。
|
462
|
+
また、これは `default_scope` ではなくて BTDM が独自にハックして暗黙的に追加する仕組みを実装しているので `.unscoped` で取り除く事は出来ないので注意してください。
|
463
|
+
|
464
|
+
```ruby
|
465
|
+
# default_scope であれば unscoped で無効化することが出来るが、BTDM のデフォルトクエリはそのまま
|
466
|
+
puts Employee.unscoped { Employee.all.to_sql }
|
467
|
+
# => 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_to" = '9999-12-31 00:00:00'
|
468
|
+
```
|
469
|
+
|
470
|
+
|
471
|
+
### 検索時にデフォルトクエリを取り除く
|
472
|
+
|
473
|
+
検索時にデフォルトクエリを取り除きたい場合、以下のスコープを使用します。
|
474
|
+
|
475
|
+
| スコープ | 動作 |
|
476
|
+
| --- | --- |
|
477
|
+
| `.ignore_valid_datetime` | 時間指定を無視する |
|
478
|
+
| `.within_deleted` | 論理削除されているレコードを含める |
|
479
|
+
| `.without_deleted` | 論理削除されているレコードを含めない |
|
480
|
+
|
481
|
+
```ruby
|
482
|
+
Timecop.freeze("2019/1/20") {
|
483
|
+
# 時間指定をしているクエリを取り除く
|
484
|
+
puts Employee.ignore_valid_datetime.to_sql
|
485
|
+
# => SELECT "employees".* FROM "employees" WHERE "employees"."transaction_to" = '9999-12-31 00:00:00'
|
486
|
+
|
487
|
+
# 論理削除しているレコードも含める
|
488
|
+
puts Employee.within_deleted.to_sql
|
489
|
+
# => SELECT "employees".* FROM "employees" WHERE "employees"."valid_from" <= '2019-01-20 00:00:00' AND "employees"."valid_to" > '2019-01-20 00:00:00'
|
490
|
+
|
491
|
+
# 全てのレコードを対象とする
|
492
|
+
puts Employee.ignore_valid_datetime.within_deleted.to_sql
|
493
|
+
# => SELECT "employees".* FROM "employees"
|
494
|
+
}
|
495
|
+
```
|
496
|
+
|
497
|
+
『任意のレコードの履歴一覧を取得する』ようなことを行う場合は `ignore_valid_datetime` を使用して全レコードを参照するようにします。
|
498
|
+
|
499
|
+
```ruby
|
500
|
+
employee = nil
|
501
|
+
Timecop.freeze("2019/1/10") {
|
502
|
+
employee = Employee.create(name: "Jane")
|
503
|
+
}
|
504
|
+
|
505
|
+
Timecop.freeze("2019/1/15") {
|
506
|
+
employee.update(name: "Tom")
|
507
|
+
}
|
508
|
+
|
509
|
+
Timecop.freeze("2019/1/20") {
|
510
|
+
employee.update(name: "Kevin")
|
511
|
+
|
512
|
+
# NOTE: bitemporal_id を参照することで同一の履歴を取得する事が出来る
|
513
|
+
pp Employee.ignore_valid_datetime.where(bitemporal_id: employee.bitemporal_id).map(&:name)
|
514
|
+
# => ["Jane", "Tom", "Kevin"]
|
515
|
+
}
|
516
|
+
```
|
517
|
+
|
518
|
+
### 時間を指定して検索する
|
519
|
+
|
520
|
+
任意の時間を指定して検索を行いたい場合、`.valid_at(datetime)` を利用する事が出来ます。
|
521
|
+
|
522
|
+
```ruby
|
523
|
+
employee1 = nil
|
524
|
+
employee2 = nil
|
525
|
+
Timecop.freeze("2019/1/10") {
|
526
|
+
employee1 = Employee.create(emp_code: "001", name: "Jane")
|
527
|
+
}
|
528
|
+
|
529
|
+
Timecop.freeze("2019/1/15") {
|
530
|
+
employee1.update(name: "Tom")
|
531
|
+
employee2 = Employee.create(emp_code: "002", name: "Homu")
|
532
|
+
}
|
533
|
+
|
534
|
+
Timecop.freeze("2019/1/20") {
|
535
|
+
# valid_at で任意の時間を参照して検索する事が出来る
|
536
|
+
puts Employee.valid_at("2019/1/10").to_sql
|
537
|
+
# => 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_to" = '9999-12-31 00:00:00'
|
538
|
+
|
539
|
+
pp Employee.valid_at("2019/1/10").map(&:name)
|
540
|
+
# => ["Jane"]
|
541
|
+
pp Employee.valid_at("2019/1/17").map(&:name)
|
542
|
+
# => ["Tom", "Homu"]
|
543
|
+
|
544
|
+
# そのまま続けてリレーション出来る
|
545
|
+
pp Employee.valid_at("2019/1/17").where(name: "Tom").first
|
546
|
+
# => #<Employee:0x000055678afd1d20
|
547
|
+
# id: 1,
|
548
|
+
# bitemporal_id: 1,
|
549
|
+
# emp_code: "001",
|
550
|
+
# name: "Tom",
|
551
|
+
# valid_from: 2019-01-15,
|
552
|
+
# valid_to: 9999-12-31,
|
553
|
+
# transaction_from: 2019-01-15,
|
554
|
+
# transaction_to: 9999-12-31>
|
555
|
+
}
|
556
|
+
```
|
557
|
+
|
558
|
+
また、特定の `id` で検索するのであれば `.find_at_time(datetime, id)` も利用できます。
|
559
|
+
|
560
|
+
```ruby
|
561
|
+
employee1 = nil
|
562
|
+
employee2 = nil
|
563
|
+
Timecop.freeze("2019/1/10") {
|
564
|
+
employee1 = Employee.create(emp_code: "001", name: "Jane")
|
565
|
+
}
|
566
|
+
|
567
|
+
Timecop.freeze("2019/1/15") {
|
568
|
+
employee1.update(name: "Tom")
|
569
|
+
employee2 = Employee.create(emp_code: "002", name: "Homu")
|
570
|
+
}
|
571
|
+
|
572
|
+
Timecop.freeze("2019/1/20") {
|
573
|
+
# 任意の時間の id のレコードを返す
|
574
|
+
pp Employee.find_at_time("2019/1/12", employee1.id)
|
575
|
+
# => #<Employee:0x000055b776d7ff18
|
576
|
+
# id: 1,
|
577
|
+
# bitemporal_id: 1,
|
578
|
+
# emp_code: "001",
|
579
|
+
# name: "Jane",
|
580
|
+
# valid_from: 2019-01-10,
|
581
|
+
# valid_to: 2019-01-15,
|
582
|
+
# transaction_from: 2019-01-15,
|
583
|
+
# transaction_to: 9999-12-31>
|
584
|
+
|
585
|
+
# 見つからなければ nil を返す
|
586
|
+
pp Employee.find_at_time("2019/1/12", employee2.id)
|
587
|
+
# => nil
|
588
|
+
|
589
|
+
# find_at_time の場合は例外を返す
|
590
|
+
pp Employee.find_at_time!("2019/1/12", employee2.id)
|
591
|
+
# => raise ActiveRecord::RecordNotFound (ActiveRecord::RecordNotFound)
|
592
|
+
}
|
593
|
+
```
|
594
|
+
|
595
|
+
|
596
|
+
## `id` と `bitemporal_id` について
|
597
|
+
|
598
|
+
BTDM のインスタンスの `id` は特殊で『レコードの `id`』ではなくて『`bitemporal_id` の値』が割り当てられています。
|
599
|
+
|
600
|
+
```ruby
|
601
|
+
employee = nil
|
602
|
+
Timecop.freeze("2019/1/10") {
|
603
|
+
employee = Employee.create(emp_code: "001", name: "Jane")
|
604
|
+
}
|
605
|
+
|
606
|
+
Timecop.freeze("2019/1/15") {
|
607
|
+
employee.update(name: "Tom")
|
608
|
+
}
|
609
|
+
|
610
|
+
Timecop.freeze("2019/1/20") {
|
611
|
+
employee.update(name: "Kevin")
|
612
|
+
|
613
|
+
# 現在のレコードの id は 1 を返す
|
614
|
+
pp Employee.first.id
|
615
|
+
# => 1
|
616
|
+
|
617
|
+
# 別の履歴レコードを参照しても id は同じ
|
618
|
+
pp Employee.find_at_time("2019/1/12", employee.id).id
|
619
|
+
# => 1
|
620
|
+
}
|
621
|
+
```
|
622
|
+
|
623
|
+
インスタンスの `id` はレコードの読み込み時に自動的に設定されています。
|
624
|
+
これは `Employee.find(employee.id)` で検索を行う際に `id` の値が `レコードの id` ではなくて `bitemporal_id` のほうが実装上都合がいい、という由来になっています。
|
625
|
+
この影響により `Employee.pluck(:id)` や `Employee.map(&:id)`、 `Employee.ids` が返す結果が微妙に異なるので注意してください。
|
626
|
+
|
627
|
+
```ruby
|
628
|
+
employee = nil
|
629
|
+
Timecop.freeze("2019/1/10") {
|
630
|
+
employee = Employee.create(emp_code: "001", name: "Jane")
|
631
|
+
}
|
632
|
+
|
633
|
+
Timecop.freeze("2019/1/15") {
|
634
|
+
employee.update(name: "Tom")
|
635
|
+
}
|
636
|
+
|
637
|
+
Timecop.freeze("2019/1/20") {
|
638
|
+
employee.update(name: "Kevin")
|
639
|
+
|
640
|
+
# DB の生 id が返ってくる
|
641
|
+
pp Employee.ignore_valid_datetime.pluck(:id)
|
642
|
+
|
643
|
+
# bitemporal_id が返ってくる
|
644
|
+
pp Employee.ignore_valid_datetime.map(&:id)
|
645
|
+
|
646
|
+
# bitemporal_id が返ってくる
|
647
|
+
pp Employee.ignore_valid_datetime.ids
|
648
|
+
}
|
649
|
+
```
|
650
|
+
|
651
|
+
レコードの内容
|
652
|
+
|
653
|
+
| id | bitemporal_id | emp_code | name | valid_from | valid_to | transaction_from | transaction_to |
|
654
|
+
| --- | --- | --- | --- | --- | --- | --- | --- |
|
655
|
+
| 1 | 1 | 001 | Jane | 2019-01-10 | 9999-12-31 | 2019-01-10 | 2019-01-15 |
|
656
|
+
| 2 | 1 | 001 | Jane | 2019-01-10 | 2019-01-15 | 2019-01-15 | 9999-12-31 |
|
657
|
+
| 3 | 1 | 001 | Tom | 2019-01-15 | 9999-12-31 | 2019-01-15 | 2019-01-20 |
|
658
|
+
| 4 | 1 | 001 | Tom | 2019-01-15 | 2019-01-20 | 2019-01-20 | 9999-12-31 |
|
659
|
+
| 5 | 1 | 001 | Kevin | 2019-01-20 | 9999-12-31 | 2019-01-20 | 9999-12-31 |
|
660
|
+
|
661
|
+
また、元々の DB の `id` は `#swapped_id` で参照する事が出来ます。
|
662
|
+
|
663
|
+
```ruby
|
664
|
+
employee = nil
|
665
|
+
Timecop.freeze("2019/1/10") {
|
666
|
+
employee = Employee.create(emp_code: "001", name: "Jane")
|
667
|
+
}
|
668
|
+
|
669
|
+
Timecop.freeze("2019/1/15") {
|
670
|
+
employee.update(name: "Tom")
|
671
|
+
}
|
672
|
+
|
673
|
+
Timecop.freeze("2019/1/20") {
|
674
|
+
employee.update(name: "Kevin")
|
675
|
+
|
676
|
+
pp Employee.first.swapped_id
|
677
|
+
# => 5
|
678
|
+
pp Employee.find_at_time("2019/1/12", employee.id).swapped_id
|
679
|
+
# => 2
|
680
|
+
}
|
681
|
+
```
|
682
|
+
|
683
|
+
まとめると BTDM のインスタンスは以下のような値を保持しています。
|
684
|
+
|
685
|
+
* `id` : `bitemporal_id` が暗黙的に設定される
|
686
|
+
* `bitemporal_id` : BTDM 共通の `id`
|
687
|
+
* `swapped_id` : DB の生 `id`
|
688
|
+
|
689
|
+
|
690
|
+
### `id` 検索の注意点
|
691
|
+
|
692
|
+
BTDM では `find_by(id: xxx)` や `where(id: xxx)` を行う場合 `id` ではなくて `bitemporal_id` を参照する必要があります。
|
693
|
+
|
694
|
+
```ruby
|
695
|
+
# NG : BTDM の場合は id 検索出来ない
|
696
|
+
Employee.find_by(id: employee.id)
|
697
|
+
|
698
|
+
# OK : bitemporal_id で検索を行う
|
699
|
+
# MEMO: id = bitemporal_id なの
|
700
|
+
# find_by(bitemporal_id: employee.id)
|
701
|
+
# でも動作するが employee.bitemporal_id と書いたほうが意図が伝わりやすい
|
702
|
+
Employee.find_by(bitemporal_id: employee.bitemporal_id)
|
703
|
+
|
704
|
+
# NG : BTDM の場合は id 検索出来ない
|
705
|
+
Employee.where(id: employee.id)
|
706
|
+
|
707
|
+
# OK : bitemporal_id で検索を行う
|
708
|
+
Employee.where(bitemporal_id: employee.bitemporal_id)
|
709
|
+
```
|
710
|
+
|
711
|
+
|
712
|
+
## Development
|
713
|
+
|
714
|
+
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.
|
715
|
+
|
716
|
+
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](https://rubygems.org).
|
717
|
+
|
718
|
+
## Contributing
|
719
|
+
|
720
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/kufu/activerecord-bitemporal.
|
721
|
+
|
722
|
+
## Copyright
|
723
|
+
|
724
|
+
See ./LICENSE
|