hoodoo 1.1.3 → 1.2.0

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.
@@ -22,7 +22,7 @@ module Hoodoo
22
22
  #
23
23
  module Secure
24
24
 
25
- # Instantiates this module when it is included:
25
+ # Instantiates this module when it is included.
26
26
  #
27
27
  # Example:
28
28
  #
@@ -64,7 +64,9 @@ module Hoodoo
64
64
  #
65
65
  # * Hoodoo::ActiveRecord::Secure#secure
66
66
  # * Hoodoo::ActiveRecord::Translated#translated
67
- # * Hoodoo::ActiveRecord::Dated#dated
67
+ # * Hoodoo::ActiveRecord::Dated#dated (if "dating_enabled?" is +true+)
68
+ # * Hoodoo::ActiveRecord::ManuallyDated#manually_dated
69
+ # (if "manual_dating_enabled?" is +true+)
68
70
  #
69
71
  # +klass+:: The ActiveRecord::Base subclass _class_ (not instance)
70
72
  # which is making the call here. This is the entity which is
@@ -86,10 +88,14 @@ module Hoodoo
86
88
  # Due to the mechanism used, dating scope must be done first or the
87
89
  # rest of the query may be invalid.
88
90
  #
89
- if klass.include?( Hoodoo::ActiveRecord::Dated )
91
+ if klass.include?( Hoodoo::ActiveRecord::Dated ) && klass.dating_enabled?()
90
92
  prevailing_scope = prevailing_scope.dated( context )
91
93
  end
92
94
 
95
+ if klass.include?( Hoodoo::ActiveRecord::ManuallyDated ) && klass.manual_dating_enabled?()
96
+ prevailing_scope = prevailing_scope.manually_dated( context )
97
+ end
98
+
93
99
  if klass.include?( Hoodoo::ActiveRecord::Secure )
94
100
  prevailing_scope = prevailing_scope.secure( context )
95
101
  end
@@ -21,7 +21,7 @@ module Hoodoo
21
21
  #
22
22
  module Translated
23
23
 
24
- # Instantiates this module when it is included:
24
+ # Instantiates this module when it is included.
25
25
  #
26
26
  # Example:
27
27
  #
@@ -29,7 +29,7 @@ module Hoodoo
29
29
  #
30
30
  module UUID
31
31
 
32
- # Instantiates this module when it is included:
32
+ # Instantiates this module when it is included.
33
33
  #
34
34
  # Example:
35
35
  #
@@ -69,10 +69,34 @@ module Hoodoo
69
69
  model.primary_key = 'id'
70
70
 
71
71
  model.validate( :on => :create ) do
72
- self.id = Hoodoo::UUID.generate() if self.id.nil?
72
+ self.id ||= Hoodoo::UUID.generate()
73
73
  end
74
74
 
75
- model.validates( :id, :uuid => true, :presence => true, :uniqueness => true )
75
+ model.validates(
76
+ :id,
77
+ {
78
+ :uuid => true,
79
+ :presence => true,
80
+ :uniqueness => true,
81
+ }
82
+ )
83
+
84
+ # We also have to remove ActiveRecord's default unscoped uniqueness
85
+ # check on 'id'. We've added an equivalent validator above.
86
+ #
87
+ # Sadly there is no API for this even as late as ActiveRecord 4.2,
88
+ # so we have to resort to fragile hackery.
89
+ #
90
+ model._validators.reject!() do | key, ignored |
91
+ key == :id
92
+ end
93
+
94
+ id_validation_callback = model._validate_callbacks.find do | callback |
95
+ callback.raw_filter.is_a?( ::ActiveRecord::Validations::UniquenessValidator ) &&
96
+ callback.raw_filter.attributes == [ :id ]
97
+ end
98
+
99
+ model._validate_callbacks.delete( id_validation_callback )
76
100
  end
77
101
 
78
102
  end
@@ -32,7 +32,7 @@ module Hoodoo
32
32
  #
33
33
  module Writer
34
34
 
35
- # Instantiates this module when it is included:
35
+ # Instantiates this module when it is included.
36
36
  #
37
37
  # Example:
38
38
  #
@@ -1,5 +1,3 @@
1
- require "uuidtools"
2
-
3
1
  module Hoodoo
4
2
 
5
3
  # Class that handles generation and validation of UUIDs. Whenever you
@@ -13,22 +11,30 @@ module Hoodoo
13
11
 
14
12
  # A regexp which, as its name suggests, only matches a string that
15
13
  # contains 16 pairs of hex digits (with upper or lower case A-F).
14
+ # Legacy value kept in case third party client code is using it.
16
15
  #
17
16
  # http://stackoverflow.com/questions/287684/regular-expression-to-validate-hex-string
18
17
  #
19
18
  MATCH_16_PAIRS_OF_HEX_DIGITS = /^([[:xdigit:]]{2}){16}$/
20
19
 
20
+ # A regexp which matches V4 UUIDs with hyphens removed. Note that
21
+ # this is more strict than MATCH_16_PAIRS_OF_HEX_DIGITS.
22
+ #
23
+ MATCH_V4_UUID = /^[a-fA-F0-9]{12}4[a-fA-F0-9]{3}[89aAbB][a-fA-F0-9]{15}$/
24
+
21
25
  # Generate a unique identifier. Returns a 32 character string.
22
26
  #
23
27
  def self.generate
24
- UUIDTools::UUID.random_create().hexdigest()
28
+ SecureRandom.uuid().gsub!( '-', '' )
25
29
  end
26
30
 
27
31
  # Checks if a UUID string is valid. Returns +true+ if so, else +false+.
28
32
  #
29
- # +uuid+:: UUID string to validate. Must be a String, 32 characters
30
- # long (as 16 hex digit pairs) and parse to a valid result
31
- # internally, according to internal UUID generation rules.
33
+ # +uuid+:: Quantity to validate.
34
+ #
35
+ # The method will only return +true+ if the input parameter is a String
36
+ # containing 32 mostly random hex digits representing a valid V4 UUID
37
+ # with hyphens removed.
32
38
  #
33
39
  # Note that the validity of a UUID says nothing about where, if anywhere,
34
40
  # it might have been used. So, just because a UUID is valid, doesn't mean
@@ -36,9 +42,7 @@ module Hoodoo
36
42
  # row in a database.
37
43
  #
38
44
  def self.valid?( uuid )
39
- uuid.is_a?( ::String ) &&
40
- ( uuid =~ MATCH_16_PAIRS_OF_HEX_DIGITS ) != nil &&
41
- UUIDTools::UUID.parse_hexdigest( uuid ).valid?()
45
+ uuid.is_a?( ::String ) && ( uuid =~ MATCH_V4_UUID ) != nil
42
46
  end
43
47
  end
44
48
  end
@@ -12,6 +12,6 @@ module Hoodoo
12
12
  # The Hoodoo gem version. If this changes, ensure that the date in
13
13
  # "hoodoo.gemspec" is correct and run "bundle install" (or "update").
14
14
  #
15
- VERSION = '1.1.3'
15
+ VERSION = '1.2.0'
16
16
 
17
17
  end
@@ -53,11 +53,13 @@ describe Hoodoo::ActiveRecord::Dated do
53
53
 
54
54
  before( :all ) do
55
55
 
56
- # Create some examples data for finding. The data has two different UUIDs
56
+ # Create some example data for finding. The data has two different UUIDs
57
57
  # which I'll referer to as A and B. The following tables contain the
58
- # historical and current records separately with their attributes.
58
+ # historical and current records separately with their attributes, with
59
+ # items created in the historical or main database tables respectively.
59
60
  #
60
61
  # Historical:
62
+ #
61
63
  # -------------------------------------------------------------------
62
64
  # uuid | data | created_at | effective_end | effective_start |
63
65
  # -------------------------------------------------------------------
@@ -67,12 +69,12 @@ describe Hoodoo::ActiveRecord::Dated do
67
69
  # B | "four" | now - 4 hours | now | now - 2 hour |
68
70
  #
69
71
  # Current:
72
+ #
70
73
  # --------------------------------
71
74
  # uuid | data | created_at |
72
75
  # --------------------------------
73
76
  # B | "five" | now - 4 hours |
74
77
  # A | "six" | now - 5 hours |
75
- #
76
78
 
77
79
  @uuid_a = Hoodoo::UUID.generate
78
80
  @uuid_b = Hoodoo::UUID.generate
@@ -111,7 +113,23 @@ describe Hoodoo::ActiveRecord::Dated do
111
113
 
112
114
  end
113
115
 
114
- context '.dated_at' do
116
+ context 'unscoped' do
117
+ it 'counts only the current records in the main database table' do
118
+ expect( model_klass.count ).to be 2
119
+ end
120
+
121
+ it 'finds only the current records in the main database table' do
122
+ expect( model_klass.pluck( :data ) ).to match_array( [ 'five', 'six' ] )
123
+ end
124
+ end
125
+
126
+ context '#dating_enabled?' do
127
+ it 'says it is automatically dated' do
128
+ expect( model_klass.dating_enabled? ).to eq( true )
129
+ end
130
+ end
131
+
132
+ context '#dated_at' do
115
133
  it 'returns counts correctly' do
116
134
  expect( model_klass.dated_at( @now - 10.hours ).count ).to be 0
117
135
  expect( model_klass.dated_at( @now ).count ).to be 2
@@ -143,7 +161,7 @@ describe Hoodoo::ActiveRecord::Dated do
143
161
 
144
162
  end
145
163
 
146
- context '.dated' do
164
+ context '#dated' do
147
165
  it 'returns counts correctly' do
148
166
  # The contents of the Context are irrelevant aside from the fact that it
149
167
  # needs a request to store the dated_at value.
@@ -207,7 +225,7 @@ describe Hoodoo::ActiveRecord::Dated do
207
225
 
208
226
  end
209
227
 
210
- context '.dated_historical_and_current' do
228
+ context '#dated_historical_and_current' do
211
229
  it 'returns counts correctly' do
212
230
  expect( model_klass.dated_historical_and_current.count ).to be 6
213
231
  end
@@ -350,19 +350,41 @@ describe Hoodoo::ActiveRecord::Finder do
350
350
 
351
351
  # ==========================================================================
352
352
 
353
- context 'acquisition_scope' do
354
- it 'SQL generation is as expected' do
355
- sql = RSpecModelFinderTest.acquisition_scope( @id ).to_sql()
353
+ context 'acquisition scope and overrides' do
354
+ def expect_sql( sql, id_attr_name )
356
355
  expect( sql ).to eq( "SELECT \"r_spec_model_finder_tests\".* "<<
357
356
  "FROM \"r_spec_model_finder_tests\" " <<
358
357
  "WHERE (" <<
359
358
  "(" <<
360
- "\"r_spec_model_finder_tests\".\"id\" = '#{ @id }' OR " <<
359
+ "\"r_spec_model_finder_tests\".\"#{ id_attr_name }\" = '#{ @id }' OR " <<
361
360
  "\"r_spec_model_finder_tests\".\"uuid\" = '#{ @id }'" <<
362
361
  ") OR " <<
363
362
  "\"r_spec_model_finder_tests\".\"code\" = '#{ @id }'" <<
364
363
  ")" )
365
364
  end
365
+
366
+ context 'acquisition_scope' do
367
+ it 'SQL generation is as expected' do
368
+ sql = RSpecModelFinderTest.acquisition_scope( @id ).to_sql()
369
+ expect_sql( sql, 'id' )
370
+ end
371
+ end
372
+
373
+ context 'acquire_with_id_substitute' do
374
+ before :each do
375
+ @alt_attr_name = 'foo'
376
+ RSpecModelFinderTest.acquire_with_id_substitute( @alt_attr_name )
377
+ end
378
+
379
+ after :each do
380
+ RSpecModelFinderTest.acquire_with_id_substitute( 'id' )
381
+ end
382
+
383
+ it 'SQL generation is as expected' do
384
+ sql = RSpecModelFinderTest.acquisition_scope( @id ).to_sql()
385
+ expect_sql( sql, @alt_attr_name )
386
+ end
387
+ end
366
388
  end
367
389
 
368
390
  # ==========================================================================
@@ -0,0 +1,776 @@
1
+ require 'spec_helper'
2
+ require 'active_record'
3
+
4
+ describe Hoodoo::ActiveRecord::ManuallyDated do
5
+
6
+ # ==========================================================================
7
+ # Data setup
8
+ # ==========================================================================
9
+
10
+ BAD_DATA_FOR_VALIDATIONS = 'bad_data'
11
+
12
+ before :all do
13
+ spec_helper_silence_stdout() do
14
+ ActiveRecord::Migration.create_table( :r_spec_model_manual_date_tests, :id => false ) do | t |
15
+ t.string :uuid, :null => false, :length => 32
16
+ t.string :id, :null => false, :length => 32
17
+
18
+ t.text :data
19
+
20
+ t.timestamps
21
+ t.datetime :effective_start, :null => false
22
+ t.datetime :effective_end, :null => false
23
+ end
24
+ end
25
+
26
+ class RSpecModelManualDateTest < ActiveRecord::Base
27
+ include Hoodoo::ActiveRecord::ManuallyDated
28
+ manual_dating_enabled()
29
+
30
+ validates_each :data do | record, attribute, value |
31
+ if value == BAD_DATA_FOR_VALIDATIONS
32
+ record.errors.add( attribute, 'contains bad text' )
33
+ end
34
+ end
35
+ end
36
+ end
37
+
38
+ # Create some example data for finding. The data has two different UUIDs
39
+ # which I'll referer to as A and B. The following tables list the
40
+ # historical and current records with their attributes. All are created
41
+ # as rows within the main test model class's one database table.
42
+ #
43
+ # The data is also seeded for any other tests, so that there's a known
44
+ # set of rows which can be examined for changes, or lack thereof.
45
+ #
46
+ # Historical:
47
+ #
48
+ # -------------------------------------------------------------------
49
+ # uuid | data | created_at | effective_end | effective_start |
50
+ # -------------------------------------------------------------------
51
+ # A | 'one' | now - 5 hours | now - 3 hours | now - 5 hours |
52
+ # B | 'two' | now - 4 hours | now - 2 hours | now - 4 hours |
53
+ # A | 'three' | now - 5 hours | now - 1 hour | now - 3 hours |
54
+ # B | 'four' | now - 4 hours | now | now - 2 hour |
55
+ #
56
+ # Current:
57
+ #
58
+ # -------------------------------------------------------------------
59
+ # uuid | data | created_at | effective_end | effective_start |
60
+ # -------------------------------------------------------------------
61
+ # B | 'five' | now - 4 hours | nil | now - 5 hours |
62
+ # A | 'six' | now - 5 hours | nil | now - 4 hours |
63
+ #
64
+ before :each do
65
+
66
+ @now = Time.now.utc.round( Hoodoo::ActiveRecord::ManuallyDated::SECONDS_DECIMAL_PLACES )
67
+ @uuid_a = Hoodoo::UUID.generate
68
+ @uuid_b = Hoodoo::UUID.generate
69
+ @eot = Hoodoo::ActiveRecord::ManuallyDated::DATE_MAXIMUM
70
+
71
+ # uuid, data, created_at, effective_end, effective_start
72
+ [
73
+ [ @uuid_a, 'one', @now - 5.hours, @now - 5.hours, @now - 3.hours ],
74
+ [ @uuid_b, 'two', @now - 4.hours, @now - 4.hours, @now - 2.hours ],
75
+ [ @uuid_a, 'three', @now - 5.hours, @now - 3.hours, @now - 1.hour ],
76
+ [ @uuid_b, 'four', @now - 4.hours, @now - 2.hours, @now ],
77
+ [ @uuid_b, 'five', @now - 4.hours, @now, @eot ],
78
+ [ @uuid_a, 'six', @now - 5.hours, @now - 1.hour, @eot ]
79
+ ].each do | row_data |
80
+ RSpecModelManualDateTest.new( {
81
+ :id => row_data[ 0 ],
82
+ :data => row_data[ 1 ],
83
+ :created_at => row_data[ 2 ],
84
+ :updated_at => row_data[ 2 ],
85
+ :effective_start => row_data[ 3 ],
86
+ :effective_end => row_data[ 4 ]
87
+ } ).save!
88
+ end
89
+
90
+ # This is a useful thing to have around! Just the bare minimum for the
91
+ # API under test. At the time of writing you can actually pass a "nil"
92
+ # context if all other attribute values are given, but that's not
93
+ # documented and besides, we want to test a mixture of context-based
94
+ # and explicitly specified parameters.
95
+ #
96
+ @context = Hoodoo::Services::Context.new( nil,
97
+ Hoodoo::Services::Request.new,
98
+ nil,
99
+ nil )
100
+ end
101
+
102
+ # ==========================================================================
103
+ # Reading tests
104
+ # ==========================================================================
105
+
106
+ context 'reading data' do
107
+ context 'unscoped' do
108
+ it 'counts all historical and current records in one database table' do
109
+ expect( RSpecModelManualDateTest.count ).to be 6
110
+ end
111
+
112
+ it 'finds all historical and current records in one database table' do
113
+ expect( RSpecModelManualDateTest.pluck( :data ) ).to match_array( [ 'one', 'two', 'three', 'four', 'five', 'six' ] )
114
+ end
115
+ end
116
+
117
+ context '#manual_dating_enabled?' do
118
+ it 'says it is manually dated' do
119
+ expect( RSpecModelManualDateTest.manual_dating_enabled? ).to eq( true )
120
+ end
121
+ end
122
+
123
+ context '#manually_dated_at' do
124
+ it 'returns counts correctly' do
125
+ expect( RSpecModelManualDateTest.manually_dated_at( @now - 10.hours ).count ).to be 0
126
+ expect( RSpecModelManualDateTest.manually_dated_at( @now ).count ).to be 2
127
+ end
128
+
129
+ def test_expectation( time, expected_data )
130
+ expect( RSpecModelManualDateTest.manually_dated_at( time ).pluck( :data ) ).to match_array( expected_data )
131
+ end
132
+
133
+ it 'returns no records before any were effective' do
134
+ test_expectation( @now - 10.hours, [] )
135
+ end
136
+
137
+ it 'returns records that used to be effective starting at past time' do
138
+ test_expectation( @now - 5.hours, [ 'one' ] )
139
+ test_expectation( @now - 4.hours, [ 'one', 'two' ] )
140
+ test_expectation( @now - 3.hours, [ 'two', 'three' ] )
141
+ test_expectation( @now - 2.hours, [ 'three', 'four' ] )
142
+ test_expectation( @now - 1.hour, [ 'four', 'six' ] )
143
+ end
144
+
145
+ it 'returns records that are effective now' do
146
+ test_expectation( @now, [ 'five', 'six' ] )
147
+ end
148
+
149
+ # Given the test above, if this was ignoring timezone or otherwise
150
+ # being confused it would take "now", subtract an hour, then add it
151
+ # back again; we'd see "five" and "six" instead of "four" and "six".
152
+ #
153
+ it 'converts inbound date/times to UTC' do
154
+ local = ( @now - 1.hour ).localtime( '+01:00' )
155
+ test_expectation( local, [ 'four', 'six' ] )
156
+ end
157
+
158
+ it 'works with further filtering' do
159
+ expect( RSpecModelManualDateTest.manually_dated_at( @now ).where( :uuid => @uuid_a ).pluck( :data ) ).to eq( [ 'six' ] )
160
+ end
161
+ end
162
+
163
+ context '#manually_dated' do
164
+ it 'returns counts correctly' do
165
+ # The contents of the Context are irrelevant aside from the fact that it
166
+ # needs a request to store the dated_at value.
167
+ request = Hoodoo::Services::Request.new
168
+ context = Hoodoo::Services::Context.new( nil, request, nil, nil )
169
+
170
+ context.request.dated_at = @now - 10.hours
171
+ expect( RSpecModelManualDateTest.manually_dated( context ).count ).to be 0
172
+
173
+ context.request.dated_at = @now
174
+ expect( RSpecModelManualDateTest.manually_dated( context ).count ).to be 2
175
+ end
176
+
177
+ def test_expectation( time, expected_data )
178
+ # The contents of the Context are irrelevant aside from the fact that it
179
+ # needs a request to store the dated_at value.
180
+ request = Hoodoo::Services::Request.new
181
+ context = Hoodoo::Services::Context.new( nil, request, nil, nil )
182
+ context.request.dated_at = time
183
+
184
+ expect( RSpecModelManualDateTest.manually_dated( context ).pluck( :data ) ).to match_array( expected_data )
185
+ end
186
+
187
+ it 'returns no records before any were effective' do
188
+ test_expectation( @now - 10.hours, [] )
189
+ end
190
+
191
+ it 'returns records that used to be effective starting at past time' do
192
+ test_expectation( @now - 5.hours, [ 'one' ] )
193
+ test_expectation( @now - 4.hours, [ 'one', 'two' ] )
194
+ test_expectation( @now - 3.hours, [ 'two', 'three' ] )
195
+ test_expectation( @now - 2.hours, [ 'three', 'four' ] )
196
+ test_expectation( @now - 1.hour, [ 'four', 'six' ] )
197
+ end
198
+
199
+ it 'returns records that are effective now' do
200
+ test_expectation( @now, [ 'five', 'six' ] )
201
+ end
202
+
203
+ it 'converts inbound date/times to UTC' do
204
+ local = ( @now - 1.hour ).localtime( '+01:00' )
205
+ test_expectation( local, [ 'four', 'six' ] )
206
+ end
207
+
208
+ it 'works with further filtering' do
209
+
210
+ # The contents of the Context are irrelevant aside from the fact that it
211
+ # needs a request to store the dated_at value.
212
+ request = Hoodoo::Services::Request.new
213
+ context = Hoodoo::Services::Context.new( nil, request, nil, nil )
214
+ context.request.dated_at = @now
215
+
216
+ expect( RSpecModelManualDateTest.manually_dated( context ).where( :uuid => @uuid_a ).pluck( :data ) ).to eq( [ 'six' ] )
217
+ end
218
+
219
+ it 'works with dating last' do
220
+
221
+ # The contents of the Context are irrelevant aside from the fact that it
222
+ # needs a request to store the dated_at value.
223
+ request = Hoodoo::Services::Request.new
224
+ context = Hoodoo::Services::Context.new( nil, request, nil, nil )
225
+ context.request.dated_at = @now
226
+
227
+ expect( RSpecModelManualDateTest.where( :uuid => @uuid_a ).manually_dated( context ).pluck( :data ) ).to eq( [ 'six' ] )
228
+ end
229
+ end
230
+
231
+ context '#manually_dated_historic' do
232
+ it 'counts only historic entries' do
233
+ expect( RSpecModelManualDateTest.manually_dated_historic.count ).to eq( 4 )
234
+ end
235
+
236
+ it 'finds only historic entries' do
237
+ expect( RSpecModelManualDateTest.manually_dated_historic.pluck( :data ) ).to match_array( [ 'one', 'two', 'three', 'four' ] )
238
+ end
239
+ end
240
+
241
+ context '#manually_dated_contemporary' do
242
+ it 'counts only contemporary entries' do
243
+ expect( RSpecModelManualDateTest.manually_dated_contemporary.count ).to eq( 2 )
244
+ end
245
+
246
+ it 'finds only contemporary entries' do
247
+ expect( RSpecModelManualDateTest.manually_dated_contemporary.pluck( :data ) ).to match_array( [ 'five', 'six' ] )
248
+ end
249
+ end
250
+ end
251
+
252
+ # ==========================================================================
253
+ # Writing tests
254
+ # ==========================================================================
255
+
256
+ context 'writing data' do
257
+ context '#manually_dated_update_in' do
258
+ before :each do
259
+ @change_data_from = Hoodoo::UUID.generate()
260
+ @change_data_to = Hoodoo::UUID.generate()
261
+
262
+ @record = RSpecModelManualDateTest.new( {
263
+ :data => @change_data_from,
264
+ :created_at => @now,
265
+ :updated_at => @now
266
+ } )
267
+
268
+ @record.save!
269
+ sleep( ( 0.1 ** Hoodoo::ActiveRecord::ManuallyDated::SECONDS_DECIMAL_PLACES ) * 2 )
270
+
271
+ @context.request.instance_variable_set( '@ident', @record.uuid )
272
+ @context.request.body = { 'data' => @change_data_to }
273
+ end
274
+
275
+ # Call only for 'successful' update cases. Pass the result of a call to
276
+ # #manually_dated_update_in. Expects:
277
+ #
278
+ # * No new 'current' items
279
+ # * One new 'historic' item
280
+ # * Two unscoped things can now be found by @record's UUID
281
+ # * They should have the correct bounding dates and data.
282
+ #
283
+ # Remember that the very top of this file seeds in 2 contemporary and
284
+ # 4 historical entries, so counts are relative to that baseline.
285
+ #
286
+ def run_expectations( result )
287
+ expect( RSpecModelManualDateTest.manually_dated_contemporary.count ).to eq( 3 )
288
+ expect( RSpecModelManualDateTest.manually_dated_historic.count ).to eq( 5 )
289
+ expect( RSpecModelManualDateTest.manually_dated_contemporary.where( :uuid => @record.uuid ).count ).to eq( 1 )
290
+ expect( RSpecModelManualDateTest.manually_dated_historic.where( :uuid => @record.uuid ).count ).to eq( 1 )
291
+
292
+ # Current record is now at 'no'/nil time - i.e. actually Time.now. The
293
+ # time frozen into "@now" at the top of this file is by this point the
294
+ # historic time of the old record.
295
+
296
+ historic = RSpecModelManualDateTest.manually_dated_at( @now ).find_by_uuid( @record.uuid )
297
+ current = RSpecModelManualDateTest.manually_dated_at().find_by_uuid( @record.uuid )
298
+
299
+ expect( result.uuid ).to eq( current.uuid )
300
+
301
+ expect( historic.data ).to eq( @change_data_from )
302
+ expect( current.data ).to eq( @change_data_to )
303
+
304
+ expect( historic.effective_end ).to eq( current.effective_start )
305
+ expect( historic.effective_end ).to eq( current.updated_at )
306
+ expect( current.effective_end ).to eq( @eot )
307
+ end
308
+
309
+ it 'via context alone' do
310
+ result = RSpecModelManualDateTest.manually_dated_update_in(
311
+ @context
312
+ )
313
+
314
+ run_expectations( result )
315
+ end
316
+
317
+ # Generate a random => invalid UUID in the request data to prove that
318
+ # the valid one given in the input parameter is used as an override.
319
+ #
320
+ it 'specifying "ident"' do
321
+ @context.request.instance_variable_set( '@ident', Hoodoo::UUID.generate() )
322
+
323
+ result = RSpecModelManualDateTest.manually_dated_update_in(
324
+ @context,
325
+ ident: @record.uuid
326
+ )
327
+
328
+ run_expectations( result )
329
+ end
330
+
331
+ # Generate a random => invalid payload in the request data to prove that
332
+ # the valid one given in the input parameter is used as an override.
333
+ #
334
+ it 'specifying "attributes"' do
335
+ @context.request.body = { Hoodoo::UUID.generate() => 42 }
336
+
337
+ result = RSpecModelManualDateTest.manually_dated_update_in(
338
+ @context,
339
+ attributes: { 'data' => @change_data_to }
340
+ )
341
+
342
+ run_expectations( result )
343
+ end
344
+
345
+ it 'uses a given scope' do
346
+
347
+ # We expect the custom scope to be customised to find an
348
+ # acquisition scope for locking, if it's being used OK.
349
+ # This is fragile; depends heavily on implementation.
350
+
351
+ custom_scope = RSpecModelManualDateTest.where( :data => [ 'one', 'two', 'three' ] + [ @change_data_from ] )
352
+ expect( custom_scope ).to receive( :acquisition_scope ).and_call_original
353
+
354
+ result = RSpecModelManualDateTest.manually_dated_update_in(
355
+ @context,
356
+ scope: custom_scope
357
+ )
358
+
359
+ run_expectations( result )
360
+ end
361
+
362
+ context 'handles not-found' do
363
+ it 'because of a bad identifier' do
364
+ result = RSpecModelManualDateTest.manually_dated_update_in(
365
+ @context,
366
+ ident: Hoodoo::UUID.generate() # Random => invalid
367
+ )
368
+
369
+ expect( result ).to be_nil
370
+ end
371
+
372
+ it 'because of a scope' do
373
+ result = RSpecModelManualDateTest.manually_dated_update_in(
374
+ @context,
375
+ scope: RSpecModelManualDateTest.where( :data => [ Hoodoo::UUID.generate() ] ) # Random => invalid
376
+ )
377
+
378
+ expect( result ).to be_nil
379
+ end
380
+ end
381
+
382
+ context 'exceptions' do
383
+ def expect_correct_rollback( &block )
384
+ starting_original = RSpecModelManualDateTest.manually_dated_contemporary.acquire_in( @context )
385
+ expect( starting_original ).to_not be_nil # Self-check this test
386
+
387
+ yield( block )
388
+
389
+ ending_original = RSpecModelManualDateTest.manually_dated_contemporary.acquire_in( @context )
390
+
391
+ expect( ending_original ).to_not be_nil
392
+ expect( starting_original.attributes ).to eq( ending_original.attributes )
393
+ end
394
+
395
+ it 'handles validation errors and does not change the contemporary record' do
396
+ expect_correct_rollback do
397
+ result = nil
398
+
399
+ expect {
400
+ result = RSpecModelManualDateTest.manually_dated_update_in(
401
+ @context,
402
+ attributes: { 'data' => BAD_DATA_FOR_VALIDATIONS }
403
+ )
404
+ }.to_not change( RSpecModelManualDateTest, :count )
405
+
406
+ expect( result ).to_not be_nil
407
+ expect( result.persisted? ).to eq( false )
408
+ expect( result.errors.messages ).to eq( { :data => [ 'contains bad text' ] } )
409
+ end
410
+ end
411
+
412
+ it 'correctly rolls back in the face of unexpected exceptions' do
413
+ expect_correct_rollback do
414
+ expect_any_instance_of( RSpecModelManualDateTest ).to receive( :save ) {
415
+ raise 'stop'
416
+ }
417
+
418
+ expect {
419
+ expect {
420
+ RSpecModelManualDateTest.manually_dated_update_in(
421
+ @context,
422
+ attributes: { 'data' => Hoodoo::UUID.generate() }
423
+ )
424
+ }.to raise_exception( RuntimeError, 'stop' )
425
+ }.to_not change( RSpecModelManualDateTest, :count )
426
+ end
427
+ end
428
+
429
+ it 'retries after one deadlock' do
430
+ raised = false
431
+
432
+ allow_any_instance_of( RSpecModelManualDateTest ).to receive( :update_column ) do | instance, name, value |
433
+ if raised == false
434
+ raised = true
435
+ raise ::ActiveRecord::StatementInvalid.new( 'MOCK DEADLOCK EXCEPTION' )
436
+ else
437
+ instance.update_attribute( name, value )
438
+ end
439
+ end
440
+
441
+ result = nil
442
+
443
+ expect {
444
+ result = RSpecModelManualDateTest.manually_dated_update_in(
445
+ @context,
446
+ attributes: { 'data' => Hoodoo::UUID.generate() }
447
+ )
448
+ }.to change( RSpecModelManualDateTest, :count ).by( 1 )
449
+
450
+ expect( result ).to_not be_nil
451
+ expect( result.errors ).to be_empty
452
+ expect( result.persisted? ).to eq( true )
453
+ end
454
+
455
+ it 'gives up after two deadlocks' do
456
+ allow_any_instance_of( RSpecModelManualDateTest ).to receive( :update_column ) do | instance, name, value |
457
+ raise ::ActiveRecord::StatementInvalid.new( 'MOCK DEADLOCK EXCEPTION' )
458
+ end
459
+
460
+ expect_correct_rollback do
461
+ expect {
462
+ result = RSpecModelManualDateTest.manually_dated_update_in(
463
+ @context,
464
+ attributes: { 'data' => Hoodoo::UUID.generate() }
465
+ )
466
+ }.to raise_exception( ::ActiveRecord::StatementInvalid )
467
+ end
468
+ end
469
+ end
470
+ end
471
+
472
+ context '#manually_dated_destruction_in' do
473
+ before :each do
474
+ @data = Hoodoo::UUID.generate()
475
+ @record = RSpecModelManualDateTest.new( {
476
+ :data => @data,
477
+ :created_at => @now,
478
+ :updated_at => @now
479
+ } )
480
+
481
+ @record.save!
482
+ sleep( ( 0.1 ** Hoodoo::ActiveRecord::ManuallyDated::SECONDS_DECIMAL_PLACES ) * 2 )
483
+
484
+ @old_updated_at = @record.updated_at
485
+
486
+ @context.request.instance_variable_set( '@ident', @record.uuid )
487
+ end
488
+
489
+ # Call only for 'successful' delete cases. Pass the result of a call to
490
+ # #manually_dated_update_in. Expects:
491
+ #
492
+ # * One fewer 'current' items
493
+ # * One more 'historic' item
494
+ # * One unscoped thing can now be found by @record's UUID
495
+ # * It should have the correct bounding dates and data.
496
+ #
497
+ # Remember that the very top of this file seeds in 2 contemporary and
498
+ # 4 historical entries, so counts are relative to that baseline.
499
+ #
500
+ def run_expectations( result )
501
+ expect( RSpecModelManualDateTest.manually_dated_contemporary.count ).to eq( 2 )
502
+ expect( RSpecModelManualDateTest.manually_dated_historic.count ).to eq( 5 )
503
+ expect( RSpecModelManualDateTest.manually_dated_contemporary.where( :uuid => @record.uuid ).count ).to eq( 0 )
504
+ expect( RSpecModelManualDateTest.manually_dated_historic.where( :uuid => @record.uuid ).count ).to eq( 1 )
505
+
506
+ # Current record is now at 'no'/nil time - i.e. actually Time.now. The
507
+ # time frozen into "@now" at the top of this file is by this point the
508
+ # historic time of the old record.
509
+
510
+ historic = RSpecModelManualDateTest.manually_dated_at( @now ).find_by_uuid( @record.uuid )
511
+
512
+ expect( historic.data ).to eq( @data )
513
+
514
+ expect( historic.effective_end ).to_not eq( @eot )
515
+ expect( historic.updated_at ).to eq( @old_updated_at )
516
+ end
517
+
518
+ it 'via context alone' do
519
+ result = RSpecModelManualDateTest.manually_dated_destruction_in(
520
+ @context
521
+ )
522
+
523
+ run_expectations( result )
524
+ end
525
+
526
+ # Generate a random => invalid UUID in the request data to prove that
527
+ # the valid one given in the input parameter is used as an override.
528
+ #
529
+ it 'specifying "ident"' do
530
+ @context.request.instance_variable_set( '@ident', Hoodoo::UUID.generate() )
531
+
532
+ result = RSpecModelManualDateTest.manually_dated_destruction_in(
533
+ @context,
534
+ ident: @record.uuid
535
+ )
536
+
537
+ run_expectations( result )
538
+ end
539
+
540
+ it 'uses a given scope' do
541
+
542
+ # We expect the custom scope to be customised to find an
543
+ # contemporary dated record for locking, if it's being used
544
+ # OK. This is fragile; depends heavily on implementation.
545
+
546
+ custom_scope = RSpecModelManualDateTest.where( :data => [ 'one', 'two', 'three' ] + [ @data ] )
547
+ expect( custom_scope ).to receive( :manually_dated_contemporary ).and_call_original
548
+
549
+ result = RSpecModelManualDateTest.manually_dated_destruction_in(
550
+ @context,
551
+ scope: custom_scope
552
+ )
553
+
554
+ run_expectations( result )
555
+ end
556
+
557
+ context 'handles not-found' do
558
+ it 'because of a bad identifier' do
559
+ result = RSpecModelManualDateTest.manually_dated_destruction_in(
560
+ @context,
561
+ ident: Hoodoo::UUID.generate() # Random => invalid
562
+ )
563
+
564
+ expect( result ).to be_nil
565
+ end
566
+
567
+ it 'because of a scope' do
568
+ result = RSpecModelManualDateTest.manually_dated_destruction_in(
569
+ @context,
570
+ scope: RSpecModelManualDateTest.where( :data => [ Hoodoo::UUID.generate() ] ) # Random => invalid
571
+ )
572
+
573
+ expect( result ).to be_nil
574
+ end
575
+ end
576
+ end
577
+
578
+ context 'update then delete' do
579
+ it 'works' do
580
+ record = RSpecModelManualDateTest.new( {
581
+ :data => Hoodoo::UUID.generate(),
582
+ :created_at => @now,
583
+ :updated_at => @now
584
+ } )
585
+
586
+ record.save!
587
+
588
+ 3.times do
589
+ result = RSpecModelManualDateTest.manually_dated_update_in(
590
+ @context,
591
+ ident: record.uuid,
592
+ attributes: { 'data' => Hoodoo::UUID.generate() }
593
+ )
594
+
595
+ expect( result ).to_not be_nil
596
+ expect( result.persisted? ).to eq( true )
597
+ end
598
+
599
+ result = RSpecModelManualDateTest.manually_dated_destruction_in(
600
+ @context,
601
+ ident: record.uuid
602
+ )
603
+
604
+ expect( result ).to_not be_nil
605
+
606
+ # We start with one current record, but it gets updated three times,
607
+ # creating three history entries. Then it gets deleted, creating
608
+ # another history entry and leaving no current ones.
609
+ #
610
+ # Remember that the very top of this file seeds in 2 contemporary and
611
+ # 4 historical entries, so counts are relative to that baseline.
612
+
613
+ expect( RSpecModelManualDateTest.manually_dated_contemporary.count ).to eq( 2 )
614
+ expect( RSpecModelManualDateTest.manually_dated_historic.count ).to eq( 8 )
615
+ expect( RSpecModelManualDateTest.manually_dated_contemporary.where( :uuid => record.uuid ).count ).to eq( 0 )
616
+ expect( RSpecModelManualDateTest.manually_dated_historic.where( :uuid => record.uuid ).count ).to eq( 4 )
617
+ end
618
+ end
619
+ end
620
+
621
+ context 'concurrent' do
622
+ before :all do
623
+ DatabaseCleaner.strategy = :truncation
624
+ end
625
+
626
+ after :all do
627
+ DatabaseCleaner.strategy = DATABASE_CLEANER_STRATEGY # spec_helper.rb
628
+ end
629
+
630
+ before :each do
631
+ @uuids = []
632
+ @results = {}
633
+ @mutex = Mutex.new
634
+
635
+ # From a pool of UUIDs, create a bunch of records.
636
+
637
+ 50.times { @uuids << Hoodoo::UUID.generate }
638
+ @uuids.uniq! # Just in case I should've entered the lottery this week
639
+
640
+ @uuids.each do | uuid |
641
+ RSpecModelManualDateTest.new( {
642
+ :uuid => uuid,
643
+ :data => '0'
644
+ } ).save!
645
+ end
646
+ end
647
+
648
+ def add_result( uuid, result )
649
+ @mutex.synchronize do
650
+ @results[ uuid ] ||= []
651
+ @results[ uuid ] << result
652
+ end
653
+ end
654
+
655
+ # - Start threads that update the records with values, one update per thread
656
+ # - Check that the combined unscoped set has all integers and nothing more
657
+ # - Check there is only one contemporary entry per UUID and num-ints-minus-one
658
+ # history entries
659
+ #
660
+ it 'updates work' do
661
+ values = ( '1'..'9' ).to_a
662
+ threads = []
663
+
664
+ @uuids.each do | uuid |
665
+ values.each do | value |
666
+ threads << Thread.new do
667
+ ActiveRecord::Base.connection_pool.with_connection do
668
+ sleep 0.001 # Force Thread scheduler to run
669
+
670
+ result = RSpecModelManualDateTest.manually_dated_update_in(
671
+ @context,
672
+ ident: uuid,
673
+ attributes: { 'data' => value }
674
+ )
675
+
676
+ add_result( uuid, result )
677
+ end
678
+ end
679
+ end
680
+ end
681
+
682
+ threads.each { | thread | thread.join() }
683
+
684
+ @uuids.each do | uuid |
685
+ contemporary = RSpecModelManualDateTest.manually_dated_contemporary.where( :uuid => uuid ).to_a
686
+ historic = RSpecModelManualDateTest.manually_dated_historic.where( :uuid => uuid ).to_a
687
+
688
+ expect( contemporary.count ).to eq( 1 )
689
+ expect( historic.count ).to eq( values.count )
690
+
691
+ # Across all records, the starting data value of "0" plus any
692
+ # new items in "values" should be present exactly once.
693
+
694
+ combined = ( contemporary + historic ).map( & :data )
695
+ expect( combined ).to match_array( [ '0' ] + values )
696
+
697
+ # All results should be persisted model instances which, once
698
+ # reloaded, only have one contemporary entry and the rest historic.
699
+ # This double-checks the database query tests a few lines above.
700
+
701
+ results = @results[ uuid ]
702
+
703
+ results.each do | result |
704
+ expect( result ).to be_a( RSpecModelManualDateTest )
705
+ expect( result.errors ).to be_empty
706
+ expect( result.persisted? ).to eq( true )
707
+
708
+ result.reload
709
+ end
710
+
711
+ dates = results.map( & :effective_end )
712
+ eots = dates.select() { | date | date == @eot }
713
+ others = dates.reject() { | date | date == @eot }
714
+
715
+ # "- 1" because the results Hash only contains results of the
716
+ # *updates* we did, not the original starting record.
717
+
718
+ expect( eots.count ).to eq( 1 )
719
+ expect( others.count ).to eq( values.count - 1 )
720
+ end
721
+ end
722
+
723
+ # - Start several threads that delete the same records
724
+ # - Push results into the results hash
725
+ # - Check that only one thread succeeded, rest of them got 'nil' (not found),
726
+ # only one history entry per UUID, no contemporary entry per UUID.
727
+ #
728
+ it 'deletions work' do
729
+ threads = []
730
+ attempts = 10
731
+
732
+ @uuids.each do | uuid |
733
+ attempts.times do
734
+ threads << Thread.new do
735
+ ActiveRecord::Base.connection_pool.with_connection do
736
+ result = RSpecModelManualDateTest.manually_dated_destruction_in(
737
+ @context,
738
+ ident: uuid
739
+ )
740
+
741
+ add_result( uuid, result )
742
+ end
743
+ end
744
+ end
745
+ end
746
+
747
+ threads.each { | thread | thread.join() }
748
+
749
+ @uuids.each do | uuid |
750
+ contemporary = RSpecModelManualDateTest.manually_dated_contemporary.where( :uuid => uuid ).to_a
751
+ historic = RSpecModelManualDateTest.manually_dated_historic.where( :uuid => uuid ).to_a
752
+
753
+ expect( contemporary.count ).to eq( 0 )
754
+ expect( historic.count ).to eq( 1 )
755
+
756
+ # We expect all results to contain one success and lots of failures.
757
+
758
+ results = @results[ uuid ]
759
+ failures = results.select() { | result | result.nil? }
760
+ successes = results.reject() { | result | result.nil? }
761
+
762
+ expect( failures.count ).to eq( attempts - 1 )
763
+ expect( successes.count ).to eq( 1 )
764
+
765
+ success = successes.first
766
+ success.reload
767
+
768
+ expect( success ).to be_a( RSpecModelManualDateTest )
769
+ expect( success.errors ).to be_empty
770
+ expect( success.persisted? ).to eq( true )
771
+ expect( success.effective_end ).to_not eq( @eot )
772
+ end
773
+ end
774
+
775
+ end
776
+ end