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 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