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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: da72ced6e70c0fc98ba5b78417421557dfd22708
4
- data.tar.gz: 6f6c9b32d810cceb820f5e6b502baef95add1c26
3
+ metadata.gz: 9e2f6c55882d65be9f8fb0ff2ef4bbc2260d7d0a
4
+ data.tar.gz: f1d2aa3879ea83b657a7c3954ad1335817c8f8ce
5
5
  SHA512:
6
- metadata.gz: 1f7c0e8f7e1db361fb037295122f03562359ba497495a279e53b9aa7d48cbecac4c2396bd5cff88ea609a5a2a62cc70060d24c9e8047f9482dca27d2633e61de
7
- data.tar.gz: 7226296b3c3e0442f9c9795e556711c8a8d7cfc963da315eaaa8f50f55c91146c356ab93c3045646c2d564086ecafb7ee2df912a0082e96942e2391bbdc56653
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.3
55
- * Active Record >= 4.0
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
- silence_warnings do
18
- # We need to override the "scoped" method on AR::Association for temporal
19
- # associations to work as well
20
- ActiveRecord::Associations::Association = ChronoModel::Patches::Association
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
@@ -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;
@@ -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
- class Association < ActiveRecord::Associations::Association
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.readonly.from(klass.history.virtual_table_at(owner.as_of_time))
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 to make ActiveAdmin work properly. This will be surely
56
- # better written in the future.
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.find(*args)
59
+ def self.with_hid_pkey(&block)
59
60
  old = self.primary_key
60
61
  self.primary_key = :hid
61
- super
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 record
108
- self.class.superclass.find(rid)
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
- build_time_query(['NULL', time_for_time_query(time, range)], range)
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
- build_time_query([time_for_time_query(time, range), 'NULL'], range)
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}) && #{table_name}.#{range.name} ]
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.readonly.from(virtual_table_at(time))
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
- readonly.where(:id => object).extend(HistorySelect)
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 += [ %[#{quoted_table_name}."recorded_at", #{quoted_table_name}."hid"] ]
544
+ self.order_values += [ ORDER_VALUES[quoted_table_name] ]
489
545
  end
490
546
 
491
547
  super.tap do |rel|
492
- rel.project("LEAST(upper(validity), timezone('UTC', now())) AS as_of_time")
548
+ rel.project(SELECT_VALUES)
493
549
  end
494
550
  end
495
551
  end
@@ -1,3 +1,3 @@
1
1
  module ChronoModel
2
- VERSION = "0.8.0"
2
+ VERSION = "0.8.2"
3
3
  end
@@ -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 'returns read only records' do
160
- it { foo.history.all?(&:readonly?).should be_true }
161
- it { bar.history.all?(&:readonly?).should be_true }
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 'returns read only associated records' do
170
- it { foo.history[2].bars.all?(&:readonly?).should be_true }
171
- it { bar.history.all? {|b| b.foo.readonly?}.should be_true }
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 '#record' do
248
- subject { foo.history.sample.record }
249
- it { should == foo }
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, :now).count.should == 3 }
540
- it { Foo.history.time_query(:before, :now).count.should == 5 }
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
@@ -87,7 +87,8 @@ describe ChronoModel::TimeMachine::TimeQuery do
87
87
  end
88
88
 
89
89
  describe :before do
90
- subject { Event.time_query(:before, time.try(:to_date) || time, on: :interval, type: :daterange).to_a }
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
- subject { Event.time_query(:after, time.try(:to_date) || time, on: :interval, type: :daterange).to_a }
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.0
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-03-29 00:00:00.000000000 Z
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: '4.0'
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: '4.0'
27
+ version: 4.0.0
28
28
  - !ruby/object:Gem::Dependency
29
29
  name: pg
30
30
  requirement: !ruby/object:Gem::Requirement