chrono_model 0.8.0 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +6 -2
- data/chrono_model.gemspec +1 -1
- data/lib/chrono_model.rb +19 -4
- data/lib/chrono_model/adapter.rb +12 -1
- data/lib/chrono_model/patches.rb +2 -2
- data/lib/chrono_model/time_machine.rb +76 -20
- data/lib/chrono_model/version.rb +1 -1
- data/spec/time_machine_spec.rb +58 -11
- data/spec/time_query_spec.rb +19 -2
- metadata +4 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 9e2f6c55882d65be9f8fb0ff2ef4bbc2260d7d0a
|
4
|
+
data.tar.gz: f1d2aa3879ea83b657a7c3954ad1335817c8f8ce
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 3b5c36f3ce8e4947038141179a04361f0b3eec0d36a7f50334a9b86903d904a852434ddfad69fa957cc6e2164e0d7dee89aa91593be4b8d72cafdfbcf7cce4d6
|
7
|
+
data.tar.gz: e1e2f4938157129ff3eaea888b67716ac1e24a9ee45bd0345c6aa1b1c381f309635f1f855eeccfb3b40d5b8008ca7ede2e66460a630306adebcd18ff4b16b2e9
|
data/README.md
CHANGED
@@ -51,8 +51,8 @@ All timestamps are _forcibly_ stored in as UTC, bypassing the
|
|
51
51
|
|
52
52
|
## Requirements
|
53
53
|
|
54
|
-
* Ruby >= 1.9.
|
55
|
-
* Active Record
|
54
|
+
* Ruby >= 2.0 (1.9 is still supported, but support will be dropped soon).
|
55
|
+
* Active Record = 4.0
|
56
56
|
* PostgreSQL >= 9.3
|
57
57
|
* The `btree_gist` PostgreSQL extension
|
58
58
|
|
@@ -230,6 +230,10 @@ SELECT "countries".* FROM (
|
|
230
230
|
|
231
231
|
More methods are provided, see the [TimeMachine][] source for more information.
|
232
232
|
|
233
|
+
## History manipulation
|
234
|
+
|
235
|
+
History objects can be changed and `.save`d just like any other record.
|
236
|
+
|
233
237
|
## Running tests
|
234
238
|
|
235
239
|
You need a running PostgreSQL 9.3 instance. Create `spec/config.yml` with the
|
data/chrono_model.gemspec
CHANGED
@@ -15,7 +15,7 @@ Gem::Specification.new do |gem|
|
|
15
15
|
gem.require_paths = ["lib"]
|
16
16
|
gem.version = ChronoModel::VERSION
|
17
17
|
|
18
|
-
gem.add_dependency "activerecord", "~> 4.0"
|
18
|
+
gem.add_dependency "activerecord", "~> 4.0.0"
|
19
19
|
gem.add_dependency "pg"
|
20
20
|
gem.add_dependency "multi_json"
|
21
21
|
end
|
data/lib/chrono_model.rb
CHANGED
@@ -14,8 +14,23 @@ if defined?(Rails)
|
|
14
14
|
require 'chrono_model/railtie'
|
15
15
|
end
|
16
16
|
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
17
|
+
# We need to override the "scoped" method on AR::Association for temporal
|
18
|
+
# associations to work. On Ruby 2.0 and up, the Module#prepend comes in
|
19
|
+
# handy - on Ruby 1.9 we have to hack the inheritance hierarchy.
|
20
|
+
#
|
21
|
+
|
22
|
+
if RUBY_VERSION.to_i >= 2
|
23
|
+
ActiveRecord::Associations::Association.instance_eval do
|
24
|
+
prepend ChronoModel::Patches::Association
|
25
|
+
end
|
26
|
+
else
|
27
|
+
ActiveSupport::Deprecation.warn 'Ruby 1.9 is deprecated. Please update your Ruby <3'
|
28
|
+
|
29
|
+
silence_warnings do
|
30
|
+
class ChronoModel::Patches::AssociationPatch < ActiveRecord::Associations::Association
|
31
|
+
include ChronoModel::Patches::Association
|
32
|
+
end
|
33
|
+
|
34
|
+
ActiveRecord::Associations::Association = ChronoModel::Patches::AssociationPatch
|
35
|
+
end
|
21
36
|
end
|
data/lib/chrono_model/adapter.rb
CHANGED
@@ -558,6 +558,13 @@ module ChronoModel
|
|
558
558
|
logger.info "ChronoModel: upgrade complete"
|
559
559
|
end
|
560
560
|
end
|
561
|
+
rescue => e
|
562
|
+
message = "ChronoModel structure upgrade failed: #{e.message}. Please drop dependent objects and then run ActiveRecord::Base.connection.chrono_setup!"
|
563
|
+
|
564
|
+
# Quite important, output it also to stderr.
|
565
|
+
#
|
566
|
+
logger.error message
|
567
|
+
$stderr.puts message
|
561
568
|
end
|
562
569
|
|
563
570
|
def chrono_metadata_for(table)
|
@@ -699,6 +706,10 @@ module ChronoModel
|
|
699
706
|
# exists, update it with new data. This logic makes possible to "squash"
|
700
707
|
# together changes made in a transaction in a single history row.
|
701
708
|
#
|
709
|
+
# If you want to disable this behaviour, set the CHRONOMODEL_NO_SQUASH
|
710
|
+
# environment variable. This is useful when running scenarios inside
|
711
|
+
# cucumber, in which everything runs in the same transaction.
|
712
|
+
#
|
702
713
|
execute <<-SQL
|
703
714
|
CREATE OR REPLACE FUNCTION chronomodel_#{table}_update() RETURNS TRIGGER AS $$
|
704
715
|
DECLARE _now timestamp;
|
@@ -721,7 +732,7 @@ module ChronoModel
|
|
721
732
|
_now := timezone('UTC', now());
|
722
733
|
_hid := NULL;
|
723
734
|
|
724
|
-
SELECT hid INTO _hid FROM #{history} WHERE #{pk} = OLD.#{pk} AND lower(validity) = _now;
|
735
|
+
#{"SELECT hid INTO _hid FROM #{history} WHERE #{pk} = OLD.#{pk} AND lower(validity) = _now;" unless ENV['CHRONOMODEL_NO_SQUASH']}
|
725
736
|
|
726
737
|
IF _hid IS NOT NULL THEN
|
727
738
|
UPDATE #{history} SET ( #{fields} ) = ( #{values} ) WHERE hid = _hid;
|
data/lib/chrono_model/patches.rb
CHANGED
@@ -11,7 +11,7 @@ module ChronoModel
|
|
11
11
|
# then the as_of scope is called on either this association's class or
|
12
12
|
# on the join model's (:through association) one.
|
13
13
|
#
|
14
|
-
|
14
|
+
module Association
|
15
15
|
|
16
16
|
# If the association class or the through association are ChronoModels,
|
17
17
|
# then fetches the records from a virtual table using a subquery scope
|
@@ -28,7 +28,7 @@ module ChronoModel
|
|
28
28
|
# For standard associations, replace the table name with the virtual
|
29
29
|
# as-of table name at the owner's as-of-time
|
30
30
|
#
|
31
|
-
scope = scope.
|
31
|
+
scope = scope.from(klass.history.virtual_table_at(owner.as_of_time))
|
32
32
|
elsif respond_to?(:through_reflection) && through_reflection.klass.chrono?
|
33
33
|
|
34
34
|
# For through associations, replace the joined table name instead.
|
@@ -52,17 +52,45 @@ module ChronoModel
|
|
52
52
|
attributes[self.class.primary_key]
|
53
53
|
end
|
54
54
|
|
55
|
-
# HACK
|
56
|
-
#
|
55
|
+
# HACK. find() and save() require the real history ID. So we are
|
56
|
+
# setting it now and ensuring to reset it to the original one after
|
57
|
+
# execution completes.
|
57
58
|
#
|
58
|
-
def self.
|
59
|
+
def self.with_hid_pkey(&block)
|
59
60
|
old = self.primary_key
|
60
61
|
self.primary_key = :hid
|
61
|
-
|
62
|
+
|
63
|
+
block.call
|
62
64
|
ensure
|
63
65
|
self.primary_key = old
|
64
66
|
end
|
65
67
|
|
68
|
+
def self.find(*)
|
69
|
+
with_hid_pkey { super }
|
70
|
+
end
|
71
|
+
|
72
|
+
if RUBY_VERSION.to_f < 2.0
|
73
|
+
# PLEASE UPDATE YOUR RUBY <3
|
74
|
+
#
|
75
|
+
def save_with_pkey(*)
|
76
|
+
self.class.with_hid_pkey { save_without_pkey }
|
77
|
+
end
|
78
|
+
|
79
|
+
def save_with_pkey!(*)
|
80
|
+
self.class.with_hid_pkey { save_without_pkey! }
|
81
|
+
end
|
82
|
+
|
83
|
+
alias_method_chain :save, :pkey
|
84
|
+
else
|
85
|
+
def save(*)
|
86
|
+
self.class.with_hid_pkey { super }
|
87
|
+
end
|
88
|
+
|
89
|
+
def save!(*)
|
90
|
+
self.class.with_hid_pkey { super }
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
66
94
|
# Returns the previous history entry, or nil if this
|
67
95
|
# is the first one.
|
68
96
|
#
|
@@ -104,8 +132,13 @@ module ChronoModel
|
|
104
132
|
|
105
133
|
# Returns this history entry's current record
|
106
134
|
#
|
107
|
-
def
|
108
|
-
self.class.
|
135
|
+
def current_version
|
136
|
+
self.class.non_history_superclass.find(rid)
|
137
|
+
end
|
138
|
+
|
139
|
+
def record #:nodoc:
|
140
|
+
ActiveSupport::Deprecation.warn '.record is deprecated in favour of .current_version'
|
141
|
+
self.current_version
|
109
142
|
end
|
110
143
|
|
111
144
|
def valid_from
|
@@ -251,6 +284,12 @@ module ChronoModel
|
|
251
284
|
timeline(options.merge(:limit => 1, :reverse => false)).first
|
252
285
|
end
|
253
286
|
|
287
|
+
# Returns the current history version
|
288
|
+
#
|
289
|
+
def current_version
|
290
|
+
self.historical? ? self.class.find(self.id) : self
|
291
|
+
end
|
292
|
+
|
254
293
|
# Returns the differences between this entry and the previous history one.
|
255
294
|
# See: +changes_against+.
|
256
295
|
#
|
@@ -316,10 +355,12 @@ module ChronoModel
|
|
316
355
|
"NOT (#{build_time_query_at(time, range)})"
|
317
356
|
|
318
357
|
when :before
|
319
|
-
|
358
|
+
op = options.fetch(:inclusive, true) ? '&&' : '@>'
|
359
|
+
build_time_query(['NULL', time_for_time_query(time, range)], range, op)
|
320
360
|
|
321
361
|
when :after
|
322
|
-
|
362
|
+
op = options.fetch(:inclusive, true) ? '&&' : '@>'
|
363
|
+
build_time_query([time_for_time_query(time, range), 'NULL'], range, op)
|
323
364
|
|
324
365
|
else
|
325
366
|
raise ArgumentError, "Invalid time_query: #{match}"
|
@@ -367,9 +408,9 @@ module ChronoModel
|
|
367
408
|
build_time_query(time, range)
|
368
409
|
end
|
369
410
|
|
370
|
-
def build_time_query(time, range)
|
411
|
+
def build_time_query(time, range, op = '&&')
|
371
412
|
if time.kind_of?(Array)
|
372
|
-
%[ #{range.type}(#{time.first}, #{time.last})
|
413
|
+
%[ #{range.type}(#{time.first}, #{time.last}) #{op} #{table_name}.#{range.name} ]
|
373
414
|
else
|
374
415
|
%[ #{time} <@ #{table_name}.#{range.name} ]
|
375
416
|
end
|
@@ -415,7 +456,7 @@ module ChronoModel
|
|
415
456
|
# Fetches as of +time+ records.
|
416
457
|
#
|
417
458
|
def as_of(time, scope = nil)
|
418
|
-
as_of = non_history_superclass.unscoped.
|
459
|
+
as_of = non_history_superclass.unscoped.from(virtual_table_at(time))
|
419
460
|
|
420
461
|
# Add default scopes back if we're passed nil or a
|
421
462
|
# specific scope, because we're .unscopeing above.
|
@@ -452,12 +493,6 @@ module ChronoModel
|
|
452
493
|
time_query(:at, time)
|
453
494
|
end
|
454
495
|
|
455
|
-
# Returns the whole history as read only.
|
456
|
-
#
|
457
|
-
def all
|
458
|
-
super.readonly
|
459
|
-
end
|
460
|
-
|
461
496
|
# Returns the history sorted by recorded_at
|
462
497
|
#
|
463
498
|
def sorted
|
@@ -470,12 +505,33 @@ module ChronoModel
|
|
470
505
|
# is maximum.
|
471
506
|
#
|
472
507
|
def of(object)
|
473
|
-
|
508
|
+
where(:id => object).extend(HistorySelect)
|
509
|
+
end
|
510
|
+
|
511
|
+
# HACK FIXME. When querying history, ChronoModel does not add his
|
512
|
+
# timestamps and sorting if there is an aggregate function in the
|
513
|
+
# select list - as it is likely what you'll want. However, if you
|
514
|
+
# have a query that performs an aggregate in a subquery, the code
|
515
|
+
# below will do the wrong thing - and you'll have to forcibly add
|
516
|
+
# back the history fields yourself.
|
517
|
+
#
|
518
|
+
# The obvious solution is to use a VIEW on the history containing
|
519
|
+
# the added history fields, and remove all this crap from here...
|
520
|
+
# but it is not easily feasible. So we're going with a workaround
|
521
|
+
# for now.
|
522
|
+
#
|
523
|
+
# - vjt Wed Apr 2 19:56:35 CEST 2014
|
524
|
+
#
|
525
|
+
def force_history_fields
|
526
|
+
select(HistorySelect::SELECT_VALUES).order(HistorySelect::ORDER_VALUES[quoted_table_name])
|
474
527
|
end
|
475
528
|
|
476
529
|
module HistorySelect #:nodoc:
|
477
530
|
Aggregates = %r{(?:(?:bit|bool)_(?:and|or)|(?:array_|string_|xml)agg|count|every|m(?:in|ax)|sum|stddev|var(?:_pop|_samp|iance)|corr|covar_|regr_)\w*\s*\(}i
|
478
531
|
|
532
|
+
SELECT_VALUES = "upper(validity) AS as_of_time"
|
533
|
+
ORDER_VALUES = lambda {|tbl| %[#{tbl}."recorded_at", #{tbl}."hid"]}
|
534
|
+
|
479
535
|
def build_arel
|
480
536
|
has_aggregate = select_values.any? do |v|
|
481
537
|
v.kind_of?(Arel::Nodes::Function) || # FIXME this is a bit ugly.
|
@@ -485,11 +541,11 @@ module ChronoModel
|
|
485
541
|
return super if has_aggregate
|
486
542
|
|
487
543
|
if order_values.blank?
|
488
|
-
self.order_values += [
|
544
|
+
self.order_values += [ ORDER_VALUES[quoted_table_name] ]
|
489
545
|
end
|
490
546
|
|
491
547
|
super.tap do |rel|
|
492
|
-
rel.project(
|
548
|
+
rel.project(SELECT_VALUES)
|
493
549
|
end
|
494
550
|
end
|
495
551
|
end
|
data/lib/chrono_model/version.rb
CHANGED
data/spec/time_machine_spec.rb
CHANGED
@@ -156,9 +156,9 @@ describe ChronoModel::TimeMachine do
|
|
156
156
|
it { bar.history.map(&:name).should == ['bar', 'foo bar', 'bar bar', 'new bar'] }
|
157
157
|
end
|
158
158
|
|
159
|
-
describe '
|
160
|
-
it { foo.history.all?(&:readonly?).
|
161
|
-
it { bar.history.all?(&:readonly?).
|
159
|
+
describe 'does not return read only records' do
|
160
|
+
it { foo.history.all?(&:readonly?).should_not be_true }
|
161
|
+
it { bar.history.all?(&:readonly?).should_not be_true }
|
162
162
|
end
|
163
163
|
|
164
164
|
describe 'takes care of associated records' do
|
@@ -166,9 +166,9 @@ describe ChronoModel::TimeMachine do
|
|
166
166
|
it { should == [nil, 'foo bar', 'new bar'] }
|
167
167
|
end
|
168
168
|
|
169
|
-
describe '
|
170
|
-
it { foo.history[2].bars.all?(&:readonly?).
|
171
|
-
it { bar.history.all? {|b| b.foo.readonly?}.
|
169
|
+
describe 'does not return read only associated records' do
|
170
|
+
it { foo.history[2].bars.all?(&:readonly?).should_not be_true }
|
171
|
+
it { bar.history.all? {|b| b.foo.readonly?}.should_not be_true }
|
172
172
|
end
|
173
173
|
|
174
174
|
describe 'allows a custom select list' do
|
@@ -244,9 +244,21 @@ describe ChronoModel::TimeMachine do
|
|
244
244
|
it { should == foo.history.last }
|
245
245
|
end
|
246
246
|
|
247
|
-
describe '#
|
248
|
-
|
249
|
-
|
247
|
+
describe '#current_version' do
|
248
|
+
describe 'on plain records' do
|
249
|
+
subject { foo.current_version }
|
250
|
+
it { should == foo }
|
251
|
+
end
|
252
|
+
|
253
|
+
describe 'from #as_of' do
|
254
|
+
subject { foo.as_of(Time.now) }
|
255
|
+
it { should == foo }
|
256
|
+
end
|
257
|
+
|
258
|
+
describe 'on historical records' do
|
259
|
+
subject { foo.history.sample.current_version }
|
260
|
+
it { should == foo }
|
261
|
+
end
|
250
262
|
end
|
251
263
|
|
252
264
|
describe '#historical?' do
|
@@ -536,8 +548,11 @@ describe ChronoModel::TimeMachine do
|
|
536
548
|
end
|
537
549
|
|
538
550
|
describe '.time_query' do
|
539
|
-
it { Foo.history.time_query(:after, :
|
540
|
-
it { Foo.history.time_query(:
|
551
|
+
it { Foo.history.time_query(:after, :now, inclusive: true ).count.should == 3 }
|
552
|
+
it { Foo.history.time_query(:after, :now, inclusive: false).count.should == 0 }
|
553
|
+
it { Foo.history.time_query(:before, :now, inclusive: true ).count.should == 5 }
|
554
|
+
it { Foo.history.time_query(:before, :now, inclusive: false).count.should == 2 }
|
555
|
+
|
541
556
|
it { Foo.history.past.size.should == 2 }
|
542
557
|
end
|
543
558
|
|
@@ -593,4 +608,36 @@ describe ChronoModel::TimeMachine do
|
|
593
608
|
end
|
594
609
|
end
|
595
610
|
|
611
|
+
# This group is below here to not to disturb the flow of the above specs.
|
612
|
+
#
|
613
|
+
context 'history modification' do
|
614
|
+
describe '#save' do
|
615
|
+
subject { bar.history.first }
|
616
|
+
|
617
|
+
before do
|
618
|
+
subject.name = 'modified bar history'
|
619
|
+
subject.save
|
620
|
+
subject.reload
|
621
|
+
end
|
622
|
+
|
623
|
+
it { should be_a(Bar::History) }
|
624
|
+
it { should be_true }
|
625
|
+
its(:name) { should == 'modified bar history' }
|
626
|
+
end
|
627
|
+
|
628
|
+
describe '#save!' do
|
629
|
+
subject { bar.history.second }
|
630
|
+
|
631
|
+
before do
|
632
|
+
subject.name = 'another modified bar history'
|
633
|
+
subject.save
|
634
|
+
subject.reload
|
635
|
+
end
|
636
|
+
|
637
|
+
it { should be_a(Bar::History) }
|
638
|
+
it { should be_true }
|
639
|
+
its(:name) { should == 'another modified bar history' }
|
640
|
+
end
|
641
|
+
end
|
642
|
+
|
596
643
|
end
|
data/spec/time_query_spec.rb
CHANGED
@@ -87,7 +87,8 @@ describe ChronoModel::TimeMachine::TimeQuery do
|
|
87
87
|
end
|
88
88
|
|
89
89
|
describe :before do
|
90
|
-
|
90
|
+
let(:inclusive) { true }
|
91
|
+
subject { Event.time_query(:before, time.try(:to_date) || time, on: :interval, type: :daterange, inclusive: inclusive).to_a }
|
91
92
|
|
92
93
|
context '16 days ago' do
|
93
94
|
let(:time) { 16.days.ago }
|
@@ -97,11 +98,21 @@ describe ChronoModel::TimeMachine::TimeQuery do
|
|
97
98
|
context '14 days ago' do
|
98
99
|
let(:time) { 14.days.ago }
|
99
100
|
it { should == [think] }
|
101
|
+
|
102
|
+
context 'not inclusive' do
|
103
|
+
let(:inclusive) { false }
|
104
|
+
it { should be_empty }
|
105
|
+
end
|
100
106
|
end
|
101
107
|
|
102
108
|
context '11 days ago' do
|
103
109
|
let(:time) { 11.days.ago }
|
104
110
|
it { should =~ [think, plan, collect] }
|
111
|
+
|
112
|
+
context 'not inclusive' do
|
113
|
+
let(:inclusive) { false }
|
114
|
+
it { should == [think, plan] }
|
115
|
+
end
|
105
116
|
end
|
106
117
|
|
107
118
|
context '10 days ago' do
|
@@ -126,7 +137,8 @@ describe ChronoModel::TimeMachine::TimeQuery do
|
|
126
137
|
end
|
127
138
|
|
128
139
|
describe :after do
|
129
|
-
|
140
|
+
let(:inclusive) { true }
|
141
|
+
subject { Event.time_query(:after, time.try(:to_date) || time, on: :interval, type: :daterange, inclusive: inclusive).to_a }
|
130
142
|
|
131
143
|
context 'one month ago' do
|
132
144
|
let(:time) { 1.month.ago }
|
@@ -161,6 +173,11 @@ describe ChronoModel::TimeMachine::TimeQuery do
|
|
161
173
|
context 'one month from now' do
|
162
174
|
let(:time) { 1.month.from_now }
|
163
175
|
it { should == [profit] }
|
176
|
+
|
177
|
+
context 'not inclusive' do
|
178
|
+
let(:inclusive) { false }
|
179
|
+
it { should be_empty }
|
180
|
+
end
|
164
181
|
end
|
165
182
|
|
166
183
|
context 'far future' do
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: chrono_model
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.8.
|
4
|
+
version: 0.8.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Marcello Barnaba
|
@@ -9,7 +9,7 @@ authors:
|
|
9
9
|
autorequire:
|
10
10
|
bindir: bin
|
11
11
|
cert_chain: []
|
12
|
-
date: 2014-
|
12
|
+
date: 2014-06-04 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: activerecord
|
@@ -17,14 +17,14 @@ dependencies:
|
|
17
17
|
requirements:
|
18
18
|
- - ~>
|
19
19
|
- !ruby/object:Gem::Version
|
20
|
-
version:
|
20
|
+
version: 4.0.0
|
21
21
|
type: :runtime
|
22
22
|
prerelease: false
|
23
23
|
version_requirements: !ruby/object:Gem::Requirement
|
24
24
|
requirements:
|
25
25
|
- - ~>
|
26
26
|
- !ruby/object:Gem::Version
|
27
|
-
version:
|
27
|
+
version: 4.0.0
|
28
28
|
- !ruby/object:Gem::Dependency
|
29
29
|
name: pg
|
30
30
|
requirement: !ruby/object:Gem::Requirement
|