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.
@@ -0,0 +1,588 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Bitemporal
5
+ module BitemporalChecker
6
+ refine ::Class do
7
+ def bi_temporal_model?
8
+ include?(ActiveRecord::Bitemporal)
9
+ end
10
+ end
11
+
12
+ refine ::ActiveRecord::Relation do
13
+ def bi_temporal_model?
14
+ klass.include?(ActiveRecord::Bitemporal)
15
+ end
16
+ end
17
+ end
18
+ using BitemporalChecker
19
+
20
+ module Optionable
21
+ def bitemporal_option
22
+ ::ActiveRecord::Bitemporal.merge_by(bitemporal_option_storage)
23
+ end
24
+
25
+ def bitemporal_option_merge!(other)
26
+ self.bitemporal_option_storage = bitemporal_option.merge other
27
+ end
28
+
29
+ def with_bitemporal_option(**opt)
30
+ tmp_opt = bitemporal_option_storage
31
+ self.bitemporal_option_storage = tmp_opt.merge(opt)
32
+ yield self
33
+ ensure
34
+ self.bitemporal_option_storage = tmp_opt
35
+ end
36
+ private
37
+ def bitemporal_option_storage
38
+ @bitemporal_option_storage ||= {}
39
+ end
40
+
41
+ def bitemporal_option_storage=(value)
42
+ @bitemporal_option_storage = value
43
+ end
44
+ end
45
+
46
+ # Add Optionable to Bitemporal
47
+ # Example:
48
+ # ActiveRecord::Bitemporal.valid_at("2018/4/1") {
49
+ # # in valid_datetime is "2018/4/1".
50
+ # }
51
+ module ::ActiveRecord::Bitemporal
52
+ class Current < ActiveSupport::CurrentAttributes
53
+ attribute :option
54
+ end
55
+
56
+ class << self
57
+ include Optionable
58
+
59
+ def valid_at(datetime, &block)
60
+ with_bitemporal_option(ignore_valid_datetime: false, valid_datetime: datetime, &block)
61
+ end
62
+
63
+ def valid_at!(datetime, &block)
64
+ with_bitemporal_option(ignore_valid_datetime: false, valid_datetime: datetime, force_valid_datetime: true, &block)
65
+ end
66
+
67
+ def valid_datetime
68
+ bitemporal_option[:valid_datetime]&.in_time_zone
69
+ end
70
+
71
+ def ignore_valid_datetime(&block)
72
+ with_bitemporal_option(ignore_valid_datetime: true, valid_datetime: nil, &block)
73
+ end
74
+
75
+ def transaction_at(datetime, &block)
76
+ with_bitemporal_option(ignore_transaction_datetime: false, transaction_datetime: datetime, &block)
77
+ end
78
+
79
+ def transaction_at!(datetime, &block)
80
+ with_bitemporal_option(ignore_transaction_datetime: false, transaction_datetime: datetime, force_transaction_datetime: true, &block)
81
+ end
82
+
83
+ def transaction_datetime
84
+ bitemporal_option[:transaction_datetime]&.in_time_zone
85
+ end
86
+
87
+ def ignore_transaction_datetime(&block)
88
+ with_bitemporal_option(ignore_transaction_datetime: true, transaction_datetime: nil, &block)
89
+ end
90
+
91
+ def merge_by(option)
92
+ option_ = option.dup
93
+ if bitemporal_option_storage[:force_valid_datetime]
94
+ option_.merge!(valid_datetime: bitemporal_option_storage[:valid_datetime])
95
+ end
96
+
97
+ if bitemporal_option_storage[:force_transaction_datetime]
98
+ option_.merge!(transaction_datetime: bitemporal_option_storage[:transaction_datetime])
99
+ end
100
+
101
+ bitemporal_option_storage.merge(option_)
102
+ end
103
+ private
104
+ def bitemporal_option_storage
105
+ Current.option ||= {}
106
+ end
107
+
108
+ def bitemporal_option_storage=(value)
109
+ Current.option = value
110
+ end
111
+ end
112
+ end
113
+
114
+ module Relation
115
+ module Finder
116
+ def find(*ids)
117
+ return super if block_given?
118
+ all.spawn.yield_self { |obj|
119
+ def obj.primary_key
120
+ "bitemporal_id"
121
+ end
122
+ obj.method(:find).super_method.call(*ids)
123
+ }
124
+ end
125
+
126
+ def find_at_time!(datetime, *ids)
127
+ valid_at(datetime).find(*ids)
128
+ end
129
+
130
+ def find_at_time(datetime, *ids)
131
+ find_at_time!(datetime, *ids)
132
+ rescue ActiveRecord::RecordNotFound
133
+ expects_array = ids.first.kind_of?(Array) || ids.size > 1
134
+ expects_array ? [] : nil
135
+ end
136
+ end
137
+ include Finder
138
+
139
+ def build_arel(*)
140
+ ActiveRecord::Bitemporal.with_bitemporal_option(**bitemporal_option) {
141
+ super
142
+ }
143
+ end
144
+
145
+ def load
146
+ return super if loaded?
147
+
148
+ # このタイミングで先読みしているアソシエーションが読み込まれるので時間を固定
149
+ records = ActiveRecord::Bitemporal.with_bitemporal_option(**bitemporal_option) { super }
150
+
151
+ return records if records.empty?
152
+
153
+ valid_datetime_ = valid_datetime
154
+ if ActiveRecord::Bitemporal.valid_datetime.nil? && (bitemporal_value[:with_valid_datetime].nil? || bitemporal_value[:with_valid_datetime] == :default_scope || valid_datetime_.nil?)
155
+ valid_datetime_ = nil
156
+ end
157
+
158
+ transaction_datetime_ = transaction_datetime
159
+ if ActiveRecord::Bitemporal.transaction_datetime.nil? && (bitemporal_value[:with_transaction_datetime].nil? || bitemporal_value[:with_transaction_datetime] == :default_scope || transaction_datetime_.nil?)
160
+ transaction_datetime_ = nil
161
+ end
162
+
163
+ return records if valid_datetime_.nil? && transaction_datetime_.nil?
164
+
165
+ records.each do |record|
166
+ record.send(:bitemporal_option_storage)[:valid_datetime] = valid_datetime_ if valid_datetime_
167
+ record.send(:bitemporal_option_storage)[:transaction_datetime] = transaction_datetime_ if transaction_datetime_
168
+ end
169
+ end
170
+
171
+ def primary_key
172
+ bitemporal_id_key
173
+ end
174
+ end
175
+
176
+ # create, update, destroy に処理をフックする
177
+ module Persistence
178
+ module EachAssociation
179
+ refine ActiveRecord::Persistence do
180
+ def each_association(
181
+ deep: false,
182
+ ignore_associations: [],
183
+ only_cached: false,
184
+ &block
185
+ )
186
+ klass = self.class
187
+ enum = Enumerator.new { |y|
188
+ reflections = klass.reflect_on_all_associations
189
+ reflections.each { |reflection|
190
+ next if only_cached && !association_cached?(reflection.name)
191
+
192
+ associations = reflection.collection? ? public_send(reflection.name) : [public_send(reflection.name)]
193
+ associations.compact.each { |asso|
194
+ next if ignore_associations.include? asso
195
+ ignore_associations << asso
196
+ y << asso
197
+ asso.each_association(deep: deep, ignore_associations: ignore_associations, only_cached: only_cached) { |it| y << it } if deep
198
+ }
199
+ }
200
+ self
201
+ }
202
+ enum.each(&block)
203
+ end
204
+ end
205
+ end
206
+ using EachAssociation
207
+
208
+ module PersistenceOptionable
209
+ include Optionable
210
+
211
+ def force_update(&block)
212
+ with_bitemporal_option(force_update: true, &block)
213
+ end
214
+
215
+ def force_update?
216
+ bitemporal_option[:force_update].present?
217
+ end
218
+
219
+ def valid_at(datetime, &block)
220
+ with_bitemporal_option(valid_datetime: datetime, &block)
221
+ end
222
+
223
+ def transaction_at(datetime, &block)
224
+ with_bitemporal_option(transaction_datetime: datetime, &block)
225
+ end
226
+
227
+ def bitemporal_option_merge_with_association!(other)
228
+ bitemporal_option_merge!(other)
229
+
230
+ # Only cached associations will be walked for performance issues
231
+ each_association(deep: true, only_cached: true).each do |association|
232
+ next unless association.respond_to?(:bitemporal_option_merge!)
233
+ association.bitemporal_option_merge!(other)
234
+ end
235
+ end
236
+
237
+ def valid_datetime
238
+ bitemporal_option[:valid_datetime]&.in_time_zone
239
+ end
240
+
241
+ def transaction_datetime
242
+ bitemporal_option[:transaction_datetime]&.in_time_zone
243
+ end
244
+ end
245
+ include PersistenceOptionable
246
+
247
+ using Module.new {
248
+ refine Persistence do
249
+ def build_new_instance
250
+ self.class.new.tap { |it|
251
+ (self.class.column_names - %w(id type created_at updated_at) - bitemporal_ignore_update_columns.map(&:to_s)).each { |name|
252
+ # 生のattributesの値でなく、ラッパーメソッド等を考慮してpublic_send(name)する
253
+ it.public_send("#{name}=", public_send(name))
254
+ }
255
+ }
256
+ end
257
+
258
+ def has_column?(name)
259
+ self.class.column_names.include? name.to_s
260
+ end
261
+
262
+ def assign_transaction_to(value)
263
+ if has_column?(:deleted_at)
264
+ assign_attributes(transaction_to: value, deleted_at: value)
265
+ else
266
+ assign_attributes(transaction_to: value)
267
+ end
268
+ end
269
+
270
+ def update_transaction_to(value)
271
+ if has_column?(:deleted_at)
272
+ update_columns(transaction_to: value, deleted_at: value)
273
+ else
274
+ update_columns(transaction_to: value)
275
+ end
276
+ end
277
+ end
278
+
279
+ refine ActiveRecord::Base do
280
+ # MEMO: Do not copy `swapped_id`
281
+ def dup(*)
282
+ super.tap { |itself|
283
+ itself.instance_exec { @_swapped_id = nil } unless itself.frozen?
284
+ }
285
+ end
286
+ end
287
+ }
288
+
289
+ def _create_record(attribute_names = self.attribute_names)
290
+ bitemporal_assign_initialize_value(valid_datetime: self.valid_datetime)
291
+
292
+ ActiveRecord::Bitemporal.valid_at!(self.valid_from) {
293
+ super()
294
+ }
295
+ end
296
+
297
+ def save(**)
298
+ ActiveRecord::Base.transaction(requires_new: true) do
299
+ self.class.where(bitemporal_id: self.id).lock!.pluck(:id) if self.id
300
+ super
301
+ end
302
+ end
303
+
304
+ def save!(**)
305
+ ActiveRecord::Base.transaction(requires_new: true) do
306
+ self.class.where(bitemporal_id: self.id).lock!.pluck(:id) if self.id
307
+ super
308
+ end
309
+ end
310
+
311
+ def _update_row(attribute_names, attempted_action = 'update')
312
+ current_valid_record, before_instance, after_instance = bitemporal_build_update_records(valid_datetime: self.valid_datetime, force_update: self.force_update?)
313
+
314
+ # MEMO: このメソッドに来るまでに validation が発動しているので、以後 validate は考慮しなくて大丈夫
315
+ ActiveRecord::Base.transaction(requires_new: true) do
316
+ current_valid_record&.update_transaction_to(current_valid_record.transaction_to)
317
+ before_instance&.save!(validate: false)
318
+ # NOTE: after_instance always exists
319
+ after_instance.save!(validate: false)
320
+
321
+ # update 後に新しく生成したインスタンスのデータを移行する
322
+ @_swapped_id = after_instance.swapped_id
323
+ self.valid_from = after_instance.valid_from
324
+
325
+ 1
326
+ # MEMO: Must return false instead of nil, if `#_update_row` failure.
327
+ end || false
328
+ end
329
+
330
+ def destroy(force_delete: false)
331
+ return super() if force_delete
332
+
333
+ current_time = Time.current
334
+ target_datetime = valid_datetime || current_time
335
+
336
+ duplicated_instance = self.class.find_at_time(target_datetime, self.id).dup
337
+
338
+ ActiveRecord::Base.transaction(requires_new: true, joinable: false) do
339
+ @destroyed = false
340
+ _run_destroy_callbacks {
341
+ @destroyed = update_transaction_to(current_time)
342
+
343
+ # 削除時の状態を履歴レコードとして保存する
344
+ duplicated_instance.valid_to = target_datetime
345
+ duplicated_instance.transaction_from = current_time
346
+ duplicated_instance.save!(validate: false)
347
+ }
348
+ raise ActiveRecord::RecordInvalid unless @destroyed
349
+
350
+ self
351
+ end
352
+ rescue => e
353
+ @destroyed = false
354
+ @_association_destroy_exception = ActiveRecord::RecordNotDestroyed.new("Failed to destroy the record: class=#{e.class}, message=#{e.message}", self)
355
+ false
356
+ end
357
+
358
+ module ::ActiveRecord::Persistence
359
+ # MEMO: Must be override ActiveRecord::Persistence#reload
360
+ alias_method :active_record_bitemporal_original_reload, :reload unless method_defined? :active_record_bitemporal_original_reload
361
+ if Gem::Version.new("7.0.0.alpha") <= ActiveRecord.version
362
+ def reload(options = nil)
363
+ return active_record_bitemporal_original_reload(options) unless self.class.bi_temporal_model?
364
+
365
+ self.class.connection.clear_query_cache
366
+
367
+ fresh_object =
368
+ ActiveRecord::Bitemporal.with_bitemporal_option(**bitemporal_option) {
369
+ if apply_scoping?(options)
370
+ _find_record(options)
371
+ else
372
+ self.class.unscoped { self.class.bitemporal_default_scope.scoping { _find_record(options) } }
373
+ end
374
+ }
375
+
376
+ @association_cache = fresh_object.instance_variable_get(:@association_cache)
377
+ @attributes = fresh_object.instance_variable_get(:@attributes)
378
+ @new_record = false
379
+ @previously_new_record = false
380
+ # NOTE: Hook to copying swapped_id
381
+ @_swapped_id = fresh_object.swapped_id
382
+ self
383
+ end
384
+ elsif Gem::Version.new("6.1.0") <= ActiveRecord.version
385
+ def reload(options = nil)
386
+ return active_record_bitemporal_original_reload(options) unless self.class.bi_temporal_model?
387
+
388
+ self.class.connection.clear_query_cache
389
+
390
+ fresh_object =
391
+ ActiveRecord::Bitemporal.with_bitemporal_option(**bitemporal_option) {
392
+ if options && options[:lock]
393
+ self.class.unscoped { self.class.lock(options[:lock]).bitemporal_default_scope.find(id) }
394
+ else
395
+ self.class.unscoped { self.class.bitemporal_default_scope.find(id) }
396
+ end
397
+ }
398
+
399
+ @attributes = fresh_object.instance_variable_get(:@attributes)
400
+ @new_record = false
401
+ @previously_new_record = false
402
+ # NOTE: Hook to copying swapped_id
403
+ @_swapped_id = fresh_object.swapped_id
404
+ self
405
+ end
406
+ else
407
+ def reload(options = nil)
408
+ return active_record_bitemporal_original_reload(options) unless self.class.bi_temporal_model?
409
+
410
+ self.class.connection.clear_query_cache
411
+
412
+ fresh_object =
413
+ ActiveRecord::Bitemporal.with_bitemporal_option(**bitemporal_option) {
414
+ if options && options[:lock]
415
+ self.class.unscoped { self.class.lock(options[:lock]).bitemporal_default_scope.find(id) }
416
+ else
417
+ self.class.unscoped { self.class.bitemporal_default_scope.find(id) }
418
+ end
419
+ }
420
+
421
+ @attributes = fresh_object.instance_variable_get("@attributes")
422
+ @new_record = false
423
+ # NOTE: Hook to copying swapped_id
424
+ @_swapped_id = fresh_object.swapped_id
425
+ self
426
+ end
427
+ end
428
+ end
429
+
430
+ private
431
+
432
+ def bitemporal_assign_initialize_value(valid_datetime:, current_time: Time.current)
433
+ # 自身の `valid_from` を設定
434
+ self.valid_from = valid_datetime || current_time if self.valid_from == ActiveRecord::Bitemporal::DEFAULT_VALID_FROM
435
+
436
+ self.transaction_from = current_time if self.transaction_from == ActiveRecord::Bitemporal::DEFAULT_TRANSACTION_FROM
437
+
438
+ # Assign only if defined created_at and deleted_at
439
+ if has_column?(:created_at)
440
+ self.transaction_from = self.created_at if changes.key?("created_at")
441
+ self.created_at = self.transaction_from
442
+ end
443
+ if has_column?(:deleted_at)
444
+ self.transaction_to = self.deleted_at if changes.key?("deleted_at")
445
+ self.deleted_at = self.transaction_to == ActiveRecord::Bitemporal::DEFAULT_TRANSACTION_TO ? nil : self.transaction_to
446
+ end
447
+ end
448
+
449
+ def bitemporal_build_update_records(valid_datetime:, current_time: Time.current, force_update: false)
450
+ target_datetime = valid_datetime || current_time
451
+ # NOTE: force_update の場合は自身のレコードを取得するような時間を指定しておく
452
+ target_datetime = valid_from_changed? ? valid_from_was : valid_from if force_update
453
+
454
+ # 対象基準日において有効なレコード
455
+ # NOTE: 論理削除対象
456
+ current_valid_record = self.class.find_at_time(target_datetime, self.id)&.tap { |record|
457
+ # 元々の id を詰めておく
458
+ record.id = record.swapped_id
459
+ record.clear_changes_information
460
+ }
461
+
462
+ # 履歴データとして保存する新しいインスタンス
463
+ # NOTE: 以前の履歴データ(現時点で有効なレコードを元にする)
464
+ before_instance = current_valid_record.dup
465
+ # NOTE: 以降の履歴データ(自身のインスタンスを元にする)
466
+ after_instance = build_new_instance
467
+
468
+ # force_update の場合は既存のレコードを論理削除した上で新しいレコードを生成する
469
+ if current_valid_record.present? && force_update
470
+ # 有効なレコードは論理削除する
471
+ current_valid_record.assign_transaction_to(current_time)
472
+ # 以前の履歴データは valid_from/to を更新しないため、破棄する
473
+ before_instance = nil
474
+ # 以降の履歴データはそのまま保存
475
+ after_instance.transaction_from = current_time
476
+
477
+ # 有効なレコードがある場合
478
+ elsif current_valid_record.present?
479
+ # 有効なレコードは論理削除する
480
+ current_valid_record.assign_transaction_to(current_time)
481
+
482
+ # 以前の履歴データは valid_to を詰めて保存
483
+ before_instance.valid_to = target_datetime
484
+ raise ActiveRecord::RecordInvalid.new(before_instance) if before_instance.valid_from_cannot_be_greater_equal_than_valid_to
485
+ before_instance.transaction_from = current_time
486
+
487
+ # 以降の履歴データは valid_from と valid_to を調整して保存する
488
+ after_instance.valid_from = target_datetime
489
+ after_instance.valid_to = current_valid_record.valid_to
490
+ raise ActiveRecord::RecordInvalid.new(after_instance) if after_instance.valid_from_cannot_be_greater_equal_than_valid_to
491
+ after_instance.transaction_from = current_time
492
+
493
+ # 有効なレコードがない場合
494
+ else
495
+ # 一番近い未来にある Instance を取ってきて、その valid_from を valid_to に入れる
496
+ nearest_instance = self.class.where(bitemporal_id: bitemporal_id).valid_from_gt(target_datetime).ignore_valid_datetime.order(valid_from: :asc).first
497
+ if nearest_instance.nil?
498
+ message = "Update failed: Couldn't find #{self.class} with 'bitemporal_id'=#{self.bitemporal_id} and 'valid_from' < #{target_datetime}"
499
+ raise ActiveRecord::RecordNotFound.new(message, self.class, "bitemporal_id", self.bitemporal_id)
500
+ end
501
+
502
+ # 有効なレコードは存在しない
503
+ current_valid_record = nil
504
+
505
+ # 以前の履歴データは有効なレコードを基準に生成するため、存在しない
506
+ before_instance = nil
507
+
508
+ # 以降の履歴データは valid_from と valid_to を調整して保存する
509
+ after_instance.valid_from = target_datetime
510
+ after_instance.valid_to = nearest_instance.valid_from
511
+ after_instance.transaction_from = current_time
512
+ end
513
+
514
+ [current_valid_record, before_instance, after_instance]
515
+ end
516
+ end
517
+
518
+ module Uniqueness
519
+ require_relative "./scope.rb"
520
+ using ::ActiveRecord::Bitemporal::Scope::ActiveRecordRelationScope
521
+
522
+ private
523
+
524
+ def scope_relation(record, relation)
525
+ finder_class = find_finder_class_for(record)
526
+ return super unless finder_class.bi_temporal_model?
527
+
528
+ relation = super(record, relation)
529
+
530
+ target_datetime = record.valid_datetime || Time.current
531
+
532
+ valid_from = record.valid_from.yield_self { |valid_from|
533
+ # NOTE: valid_from が初期値の場合は現在の時間を基準としてバリデーションする
534
+ # valid_from が初期値の場合は Persistence#_create_record に Time.current が割り当てられる為
535
+ # バリデーション時と生成時で若干時間がずれてしまうことには考慮する
536
+ if valid_from == ActiveRecord::Bitemporal::DEFAULT_VALID_FROM
537
+ target_datetime
538
+ # NOTE: 新規作成時以外では target_datetime の値を基準としてバリデーションする
539
+ # 更新時にバリデーションする場合、valid_from の時間ではなくて target_datetime の時間を基準としているため
540
+ # valdi_from を基準としてしまうと整合性が取れなくなってしまう
541
+ elsif !record.new_record?
542
+ target_datetime
543
+ else
544
+ valid_from
545
+ end
546
+ }
547
+
548
+ # MEMO: `force_update` does not refer to `valid_datetime`
549
+ valid_from = record.valid_from if record.force_update?
550
+
551
+ valid_to = record.valid_to.yield_self { |valid_to|
552
+ # レコードを更新する時に valid_datetime が valid_from ~ valid_to の範囲外だった場合、
553
+ # 一番近い未来の履歴レコードを参照して更新する
554
+ # という仕様があるため、それを考慮して valid_to を設定する
555
+ if (record.valid_datetime && (record.valid_from..record.valid_to).cover?(record.valid_datetime)) == false && (record.persisted?)
556
+ finder_class.ignore_valid_datetime.where(bitemporal_id: record.bitemporal_id).valid_from_gt(target_datetime).order(valid_from: :asc).first.valid_from
557
+ else
558
+ valid_to
559
+ end
560
+ }
561
+
562
+ valid_at_scope = finder_class.unscoped.ignore_valid_datetime
563
+ .valid_from_lt(valid_to).valid_to_gt(valid_from)
564
+ .yield_self { |scope|
565
+ # MEMO: #dup などでコピーした場合、id は存在しないが swapped_id のみ存在するケースがあるので
566
+ # id と swapped_id の両方が存在する場合のみクエリを追加する
567
+ record.id && record.swapped_id ? scope.where.not(id: record.swapped_id) : scope
568
+ }
569
+
570
+ # MEMO: Must refer Time.current, when not new record
571
+ # Because you don't want transaction_from to be rewritten
572
+ transaction_from = if record.transaction_from == ActiveRecord::Bitemporal::DEFAULT_TRANSACTION_FROM
573
+ Time.current
574
+ elsif !record.new_record?
575
+ Time.current
576
+ else
577
+ record.transaction_from
578
+ end
579
+ transaction_to = record.transaction_to || ActiveRecord::Bitemporal::DEFAULT_TRANSACTION_TO
580
+ transaction_at_scope = finder_class.unscoped
581
+ .transaction_to_gt(transaction_from)
582
+ .transaction_from_lt(transaction_to)
583
+
584
+ relation.merge(valid_at_scope.with_valid_datetime).merge(transaction_at_scope.with_transaction_datetime)
585
+ end
586
+ end
587
+ end
588
+ end