chrono_model 0.8.0 → 0.8.2
Sign up to get free protection for your applications and to get access to all the features.
- 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
|