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.
checksums.yaml CHANGED
@@ -1,15 +1,15 @@
1
1
  ---
2
2
  !binary "U0hBMQ==":
3
3
  metadata.gz: !binary |-
4
- NWM1MTMyZWRmNGNkZDExY2IyYThiYjdhZDA2OGJmNWFiNTY2NDY4ZA==
4
+ MjI0N2Q4YmRmMTIyYzA5NDhiZTdmYjY3YjBiNDg2ZWI4ZTQ5ODU2MQ==
5
5
  data.tar.gz: !binary |-
6
- N2YwMGE5ODIxNmFiNzEzMDc4YjE3YTk1ZWZiOTk2ODJhNTJmNjFhOQ==
6
+ M2U5ZjY5OTEyZWI4Y2NiN2M5M2VjM2MyNzYyZWQyNGQ3YzdmZThkZQ==
7
7
  SHA512:
8
8
  metadata.gz: !binary |-
9
- MmY2ODExNTRkZmJiYThmN2MyNGFkYTk0MjU4YjBhMzFkMTExZmU2YmNiMDNk
10
- MjAwYzQ4NzRiM2FlMTA5NjQxNjdlMzM3OTA3YzQ3NzA2N2FlMTRhYTI4OGY4
11
- NWUzNjFlNTE4NjdiNmE4YzMwYmZmMTJkZTg4NTdiZWVlYTVkODg=
9
+ Mjc4MGE2ZjMyMDNkNGMzYTViNzM5YmEyOTQ1M2M1ZTgyZGYwNjA4N2M1ZThl
10
+ ODNkOTZjNTcwNjQyNmY2OGRmMmRlOTE5ZTEyZTljYTg3MTI5MjZlNmIzYzY2
11
+ NTUwZWQ0ZmVjZDgxNzRkYzRmZWQ0YTAwM2U2MzE2NWNiMTNmYjI=
12
12
  data.tar.gz: !binary |-
13
- ZjViMjVmM2FjMzY5YzBmYTU2NTljM2FiMjAwNWU3OTRiYzZhZjdiMmMzNzgz
14
- MmE2ZmNjYWE2NTA4MGU4ODkwNjhlOTI0OGRlNjQ5ZjM1ZjIxYTI0ZjA5YWMz
15
- ZGQ1OWIyYzNhMzBmZTU4Njk2Nzc0YmJjNWI4OGNhYmY4NDIwNjY=
13
+ MjY2N2QwNjViZjhiYjM1NDBkNDczMGVkNDVlOTczYzVlOTRlYzM2NGQ4ODEx
14
+ NDFkNDI4YjI1NmYzZTVlNmNmYjdiMDhlZWU1NDk1YTRhNjg1YzE0YjNmZDY2
15
+ ZmM5NWM5MTc5OTEyOGQzN2I4MTUwMDE2NTRlNGQ5NTQ3MWVjYjY=
@@ -22,6 +22,7 @@ require 'hoodoo/active/active_record/error_mapping'
22
22
 
23
23
  require 'hoodoo/active/active_record/secure'
24
24
  require 'hoodoo/active/active_record/dated'
25
+ require 'hoodoo/active/active_record/manually_dated'
25
26
  require 'hoodoo/active/active_record/translated'
26
27
  require 'hoodoo/active/active_record/finder'
27
28
 
@@ -22,6 +22,7 @@ module Hoodoo
22
22
  #
23
23
  # * Hoodoo::ActiveRecord::Secure
24
24
  # * Hoodoo::ActiveRecord::Dated
25
+ # * Hoodoo::ActiveRecord::ManuallyDated
25
26
  # * Hoodoo::ActiveRecord::Translated
26
27
  # * Hoodoo::ActiveRecord::Finder
27
28
  # * Hoodoo::ActiveRecord::UUID
@@ -29,12 +30,17 @@ module Hoodoo
29
30
  # * Hoodoo::ActiveRecord::Writer
30
31
  # * Hoodoo::ActiveRecord::ErrorMapping
31
32
  #
33
+ # ...but not necessarily _activate_ those modules. For example,
34
+ # the Hoodoo::ActiveRecord::Dated module must be activated by a
35
+ # call to Hoodoo::ActiveRecord::Dated.dating_enabled.
36
+ #
32
37
  class Base < ::ActiveRecord::Base
33
38
 
34
39
  # Reading data.
35
40
  #
36
41
  include Hoodoo::ActiveRecord::Secure
37
42
  include Hoodoo::ActiveRecord::Dated
43
+ include Hoodoo::ActiveRecord::ManuallyDated
38
44
  include Hoodoo::ActiveRecord::Translated
39
45
  include Hoodoo::ActiveRecord::Finder
40
46
 
@@ -62,6 +68,7 @@ module Hoodoo
62
68
 
63
69
  Hoodoo::ActiveRecord::Secure.instantiate( model )
64
70
  Hoodoo::ActiveRecord::Dated.instantiate( model )
71
+ Hoodoo::ActiveRecord::ManuallyDated.instantiate( model )
65
72
  Hoodoo::ActiveRecord::Translated.instantiate( model )
66
73
  Hoodoo::ActiveRecord::Finder.instantiate( model )
67
74
 
@@ -38,7 +38,7 @@ module Hoodoo
38
38
  #
39
39
  module Creator
40
40
 
41
- # Instantiates this module when it is included:
41
+ # Instantiates this module when it is included.
42
42
  #
43
43
  # Example:
44
44
  #
@@ -108,7 +108,7 @@ module Hoodoo
108
108
  #
109
109
  module Dated
110
110
 
111
- # Instantiates this module when it is included:
111
+ # Instantiates this module when it is included.
112
112
  #
113
113
  # Example:
114
114
  #
@@ -179,6 +179,13 @@ module Hoodoo
179
179
 
180
180
  end
181
181
 
182
+ # If a prior call has been made to #dating_enabled then this method
183
+ # returns +true+, else +false+.
184
+ #
185
+ def dating_enabled?
186
+ return self.dated_with() != nil?
187
+ end
188
+
182
189
  # Return an ActiveRecord::Relation containing the model instances which
183
190
  # are effective at +context.request.dated_at+. If this value is nil the
184
191
  # current time in UTC is used.
@@ -35,7 +35,7 @@ module Hoodoo
35
35
  #
36
36
  module Finder
37
37
 
38
- # Instantiates this module when it is included:
38
+ # Instantiates this module when it is included.
39
39
  #
40
40
  # Example:
41
41
  #
@@ -44,12 +44,15 @@ module Hoodoo
44
44
  # # ...
45
45
  # end
46
46
  #
47
+ # Depends upon and auto-includes Hoodoo::ActiveRecord::Secure.
48
+ #
47
49
  # +model+:: The ActiveRecord::Base descendant that is including
48
50
  # this module.
49
51
  #
50
52
  def self.included( model )
51
53
  model.class_attribute(
52
54
  :nz_co_loyalty_hoodoo_show_id_fields,
55
+ :nz_co_loyalty_hoodoo_show_id_substitute,
53
56
  :nz_co_loyalty_hoodoo_search_with,
54
57
  :nz_co_loyalty_hoodoo_filter_with,
55
58
  {
@@ -237,9 +240,11 @@ module Hoodoo
237
240
  # *args:: One or more field names as Strings or Symbols.
238
241
  #
239
242
  # See also: #acquired_with
243
+ # #acquire_with_id_substitute
240
244
  #
241
245
  def acquire_with( *args )
242
- self.nz_co_loyalty_hoodoo_show_id_fields = args.map( & :to_s ).uniq!()
246
+ self.nz_co_loyalty_hoodoo_show_id_fields = args.map( & :to_s )
247
+ self.nz_co_loyalty_hoodoo_show_id_fields.uniq!()
243
248
  end
244
249
 
245
250
  # Return the list of model fields _in_ _addition_ _to_ +id+ which
@@ -248,16 +253,45 @@ module Hoodoo
248
253
  # values only.
249
254
  #
250
255
  # See also: #acquire_with
256
+ # #acquire_with_id_substitute
251
257
  #
252
258
  def acquired_with
253
259
  self.nz_co_loyalty_hoodoo_show_id_fields || []
254
260
  end
255
261
 
262
+ # The #acquire_with method allows methods like #acquire and
263
+ # #acquire_in to transparently find a record based on _one_ _or_
264
+ # _more_ columns in the database. The columns (and corresponding
265
+ # model attributes) specified through a call to #acquire_with will
266
+ # normally be used _in_ _addition_ _to_ a lookup on the +id+
267
+ # column, but in rare circumstances you might need to bypass that
268
+ # and use an entirely different field. This is distinct from the
269
+ # ActiveRecord-level concept of the model's primary key column.
270
+ #
271
+ # To permanently change the use of the +id+ attribute as the first
272
+ # search parameter in #acquire and #acquire_in, by modifying the
273
+ # behaviour of #acquisition_scope, call here and pass in the new
274
+ # attribute name.
275
+ #
276
+ # +attr+:: Attribute name as a Symbol or String to use _instead_
277
+ # of +id+, as a default mandatory column in
278
+ # #acquisition_scope.
279
+ #
280
+ def acquire_with_id_substitute( attr )
281
+ self.nz_co_loyalty_hoodoo_show_id_substitute = attr.to_sym
282
+ end
283
+
256
284
  # Back-end to #acquire and therefore, in turn, #acquire_in. Returns
257
285
  # an ActiveRecord::Relation instance which scopes the search for a
258
286
  # record by +id+ and across any other columns specified by
259
287
  # #acquire_with, via SQL +OR+.
260
288
  #
289
+ # If you need to change the use of attribute +id+, specify a
290
+ # different attribute with #acquire_with_id_substitute. In that case,
291
+ # the given attribute is searched for instead of +id+; either way, a
292
+ # default starting attribute _will_ be used in scope in addition to
293
+ # any extra fields specified using #acquire_with.
294
+ #
261
295
  # Normally such a scope could only ever return a single record based
262
296
  # on an assuption of uniqueness constraints around columns which one
263
297
  # might use in an equivalent of a +find+ call. In some instances
@@ -269,7 +303,7 @@ module Hoodoo
269
303
  def acquisition_scope( ident )
270
304
  extra_fields = self.acquired_with()
271
305
  arel_table = self.arel_table()
272
- arel_query = arel_table[ :id ].eq( ident )
306
+ arel_query = arel_table[ self.nz_co_loyalty_hoodoo_show_id_substitute || :id ].eq( ident )
273
307
 
274
308
  extra_fields.each do | field |
275
309
  arel_query = arel_query.or( arel_table[ field ].eq( ident ) )
@@ -0,0 +1,710 @@
1
+ ########################################################################
2
+ # File:: manually_dated.rb
3
+ # (C):: Loyalty New Zealand 2015
4
+ #
5
+ # Purpose:: Support mixin for models subclassed from ActiveRecord::Base
6
+ # providing as-per-API-standard dating support.
7
+ # ----------------------------------------------------------------------
8
+ # 14-Jul-2015 (ADH): Created.
9
+ # 21-Jul-2015 (RJS): Functionality implemented.
10
+ ########################################################################
11
+
12
+ module Hoodoo
13
+ module ActiveRecord
14
+
15
+ # Support mixin for models subclassed from ActiveRecord::Base providing
16
+ # as-per-API-standard dating support with services needing to know that
17
+ # dating is enabled and cooperate with this mixin's API, rather than
18
+ # working automatically via database triggers as per
19
+ # Hoodoo::ActiveRecord::Dated. The latter is close to transparent for
20
+ # ActiveRecord-based code, but it involves very complex database queries
21
+ # that can have high cost and is tied into PostgreSQL.
22
+ #
23
+ # Depends upon and auto-includes Hoodoo::ActiveRecord::Finder.
24
+ #
25
+ # == Overview
26
+ #
27
+ # This mixin lets you record and retrieve the historical state of any
28
+ # given ActiveRecord model. This is achieved by adding two date/time
29
+ # columns to the model and using these to track the start (inclusive) and
30
+ # end (exclusive and always set to precisely DATE_MAXIMUM for "this is the
31
+ # 'contemporary' record) date/times for which a particular row is valid.
32
+ #
33
+ # The majority of the functionality is implemented within class methods
34
+ # defined in module Hoodoo::ActiveRecord::ManuallyDated::ClassMethods.
35
+ #
36
+ # == Prerequisites
37
+ #
38
+ # A table in the database needs to have various changes and additions to
39
+ # support manual dating. For these to be possible:
40
+ #
41
+ # * Your database table may not already have columns called +uuid+,
42
+ # +effective_start+ or +effective_end+. If it does, you'll need to first
43
+ # migrate this to change the names and update any references in code.
44
+ #
45
+ # * Your database table must have a column called +created_at+ with the
46
+ # creation timestamp of a record which will become the time from which
47
+ # it is "visible" in historically-dated read queries. There can be no
48
+ # +NULL+ values in this column.
49
+ #
50
+ # * Your database table must have a column called +updated_at+ with a
51
+ # non +NULL+ value. If this isn't already present, migrate your data
52
+ # to add it, setting the initial value to the same as +created_at+.
53
+ #
54
+ # For data safety it is very strongly recommended that you add in database
55
+ # level non-null constraints on +created_at+ and +updated_at+ if you don't
56
+ # have them already. The ActiveRecord +change_column_null+ method can be
57
+ # used in migrations to do this in a database-engine-neutral fashion.
58
+ #
59
+ # == Vital caveats
60
+ #
61
+ # Since both the 'contemporary' and historic states of the model are all
62
+ # recorded in one table, anyone using this mechanism must ensure that
63
+ # (unless they specifically want to run a query across all of the
64
+ # representations) the mixin's scoping methods are _always_ used to target
65
+ # either current, or historic, or specifically-dated rows only.
66
+ #
67
+ # With this mechanism in place, the +id+ attribute of the model is _still_
68
+ # _a_ _unique_ _primary_ _key_ AND THIS IS *NO* *LONGER* THE RESOURCE
69
+ # UUID. The UUID moves to a _non-unique_ +uuid+ column. When rendering
70
+ # resources, YOU *MUST* USE THE +uuid+ COLUMN for the resource ID. This
71
+ # is a potentially serious gotcha and strong test coverage is advised! If
72
+ # you send back the wrong field value, it'll look like a reasonable UUID
73
+ # but will not match any records at all through API-based interfaces,
74
+ # assuming Hoodoo::ActiveRecord::Finder is in use for read-based queries.
75
+ # The UUID will appear to refer to a non-existant resource.
76
+ #
77
+ # * The +id+ column becomes a unique database primary key and of little
78
+ # to no interest whatsoever to a service or API callers.
79
+ #
80
+ # * The +uuid+ column becomes the non-unique resource UUID which is of
81
+ # great interest to a service and API callers.
82
+ #
83
+ # * The +uuid+ column is also the target for foreign keys with
84
+ # relationships between records, NOT +id+. The relationships can
85
+ # only be used when scoped by date.
86
+ #
87
+ # == Accuracy
88
+ #
89
+ # Time accuracy is intentionally limited, to aid database indices and help
90
+ # avoid clock accuracy differences across operating systems or datbase
91
+ # engines. Hoodoo::ActiveRecord::ManuallyDated::SECONDS_DECIMAL_PLACES
92
+ # describes the accuracy applicable.
93
+ #
94
+ # If a record is, say, both created and then deleted within the accuracy
95
+ # window, then a dated query attempting to read the resource state from
96
+ # that (within-accuracy) identical time will return an undefined result.
97
+ # It might find the resource before it were deleted, or might not find the
98
+ # resource because it considers it to be no longer current. Of course, any
99
+ # dated query from outside the accuracy window will work as you would
100
+ # expect; only rapid changes in state within the accuracy window result in
101
+ # ambiguity.
102
+ #
103
+ # == Typical workflow
104
+ #
105
+ # Having included the mixin, run any required migrations (see below) and
106
+ # declared manual dating as active inside your <tt>ActiveRecord::Base</tt>
107
+ # subclass by calling
108
+ # Hoodoo::ActiveRecord::ManuallyDated::ClassMethods#manual_dating_enabled,
109
+ # you *MUST* include the ActiveRecord::Relation instances (scopes) inside
110
+ # any query chain used to read or write data.
111
+ #
112
+ # You might use Hoodoo::ActiveRecord::Finder#list_in or
113
+ # Hoodoo::ActiveRecord::Finder#acquire_in for +list+ or +show+ actions;
114
+ # such code changes from e.g.:
115
+ #
116
+ # SomeModel.list_in( context )
117
+ #
118
+ # ...to:
119
+ #
120
+ # SomeModel.manually_dated( context ).list_in( context )
121
+ #
122
+ # You MUST NOT update or delete records using conventional ActiveRecord
123
+ # methods if you want to use manual dating to record state changes.
124
+ # Instead, use
125
+ # Hoodoo::ActiveRecord::ManuallyDated::ClassMethods#manually_dated_update_in
126
+ # or
127
+ # Hoodoo::ActiveRecord::ManuallyDated::ClassMethods#manually_dated_destruction_in.
128
+ # For example to update a model based on the +context.request.body+ data
129
+ # without changes to the item in +context.request.ident+, handling "not
130
+ # found" or valiation error cases with the assumption that the
131
+ # Hoodoo::ActiveRecord::ErrorMapping mixin is in use, do this:
132
+ #
133
+ # result = SomeModel.manually_dated_destruction_in( context )
134
+ #
135
+ # if result.nil?
136
+ # context.response.not_found( context.request.ident )
137
+ # elsif result.adds_errors_to?( context.response.errors ) == false
138
+ # rendered_data = render_model( result )
139
+ # context.response.set_data( rendered_data )
140
+ # end
141
+ #
142
+ # See the documentation for the update/destroy methods mentioned above for
143
+ # information on overriding the identifier used to find the target record
144
+ # and the attribute data used for updates.
145
+ #
146
+ # When rendering, you *MUST* remember to set the resource's +id+ field
147
+ # from the model's +uuid+ field:
148
+ #
149
+ # SomePresenter.render_in(
150
+ # context,
151
+ # model.attributes,
152
+ # {
153
+ # :uuid => model.uuid, # <-- ".uuid" - IMPORTANT!
154
+ # :created_at => model.created_at
155
+ # }
156
+ # )
157
+ #
158
+ # Likewise, remember to set foreign keys for any relational declarations
159
+ # via the +uuid+ column - e.g. go from this:
160
+ #
161
+ # member.account_id = account.id
162
+ #
163
+ # ...to this:
164
+ #
165
+ # member.account_id = account.uuid
166
+ #
167
+ # ...with the relational declarations in Member changing from:
168
+ #
169
+ # belongs_to :account
170
+ #
171
+ # ...to:
172
+ #
173
+ # belongs_to :account, :primary_key => :uuid
174
+ #
175
+ # == Required migrations
176
+ #
177
+ # You must write an ActiveRecord migration for any table that wishes to
178
+ # use manual dating. The template below can handle multiple tables in one
179
+ # pass and can be rolled back safely *IF* no historic records have been
180
+ # added. Rollback becomes impossible once historic entries appear.
181
+ #
182
+ # require 'hoodoo/active'
183
+ #
184
+ # class ConvertToManualDating < ActiveRecord::Migration
185
+ #
186
+ # # This example migration can handle multiple tables at once - e.g. pass an
187
+ # # array of ":accounts, :members" if you were adding manual dating support to
188
+ # # tables supporting an Account and Member ActiveRecord model.
189
+ # #
190
+ # TABLES_TO_CONVERT = [ :table_name, :another_table_name, ... ]
191
+ #
192
+ # def up
193
+ #
194
+ # # If you have any uniqueness constraints on this table, you'll need to
195
+ # # remove them and re-add them with date-based scope. The main table will
196
+ # # contain duplicated entries once historical versions of a row appear.
197
+ # #
198
+ # # remove_index :table_name, <index fields(s) or name: 'index name'>
199
+ # #
200
+ # # For example, suppose you had declared this index somewhere:
201
+ # #
202
+ # # add_index :accounts, :account_number, :unique => true
203
+ # #
204
+ # # Remove it with:
205
+ # #
206
+ # # remove_index :accounts, :account_number
207
+ #
208
+ # TABLES_TO_CONVERT.each do | table |
209
+ #
210
+ # add_column table, :effective_start, :datetime, :null => true # (initially, but see below)
211
+ # add_column table, :effective_end, :datetime, :null => true # (initially, but see below)
212
+ # add_column table, :uuid, :string, :limit => 32
213
+ #
214
+ # add_index table, [ :effective_start, :effective_end ], :name => "index_#{ table }_start_end"
215
+ # add_index table, [ :uuid, :effective_start, :effective_end ], :name => "index_#{ table }_id_start_end"
216
+ # add_index table, [ :uuid, :effective_end ], :unique => true, :name => "index_#{ table }_id_end"
217
+ #
218
+ # # If there's any data in the table already, it can't have any historic
219
+ # # entries. So, we want to set the UUID to the 'id' field's old value,
220
+ # # but we can also leave the 'id' field as-is. New rows for historical
221
+ # # entries will acquire a new value of 'id' via Hoodoo.
222
+ # #
223
+ # execute "UPDATE #{ table } SET uuid = id"
224
+ #
225
+ # # This won't follow the date/time rounding described by manual dating
226
+ # # but it's good enough for an initial migration.
227
+ # #
228
+ # execute "UPDATE #{ table } SET effective_start = created_at"
229
+ #
230
+ # # Mark these records as contemporary/current.
231
+ # #
232
+ # execute "UPDATE #{ table } SET effective_end = '#{ ActiveRecord::Base.connection.quoted_date( Hoodoo::ActiveRecord::ManuallyDated::DATE_MAXIMUM ) }'"
233
+ #
234
+ # # We couldn't add the UUID column with a not-null constraint until the
235
+ # # above SQL had run to update any existing records with a value. Now we
236
+ # # should put this back in, for rigour. Likewise for the start/end times.
237
+ # #
238
+ # change_column_null table, :uuid, false
239
+ # change_column_null table, :effective_start, false
240
+ # change_column_null table, :effective_end, false
241
+ #
242
+ # end
243
+ #
244
+ # # Now add back any indices dropped earlier, but add them as two composite
245
+ # # indices each - one with just :effective_end added, the other with both
246
+ # # :effective_start and :effective_end added.
247
+ # #
248
+ # # For example, suppose you had declared this index somewhere:
249
+ # #
250
+ # # add_index :accounts, :account_number, :unique => true
251
+ # #
252
+ # # You need to have done "remove_index :accounts, :account_number" earlier;
253
+ # # then now add the two new equivalents. You may well find you have to give
254
+ # # them custom names to avoid hitting index name length limits in your
255
+ # # database if ActiveRecord is allowed to generate a name automatically:
256
+ # #
257
+ # # add_index :accounts, [ :account_number, :effective_start, :effective_end ], :name => 'index_accounts_an_es_ee'
258
+ # # add_index :accounts, [ :account_number, :effective_end ], :unique => true, :name => 'index_accounts_an_ee'
259
+ # #
260
+ # # You might want to perform more detailed analysis on your index
261
+ # # requirements once manual dating is enabled, but the above is a good rule
262
+ # # of thumb.
263
+ #
264
+ # end
265
+ #
266
+ # # This would fail if any historic entries now existed in the database,
267
+ # # because primary key 'id' values would get set to non-unique 'uuid'
268
+ # # values. This is intentional and required to avoid corruption; you
269
+ # # cannot roll back once history entries accumulate.
270
+ # #
271
+ # def down
272
+ #
273
+ # # Remove any indices added manually at the end of "up", for example:
274
+ # #
275
+ # # remove_index :accounts, :name => 'index_accounts_an_es_ee'
276
+ # # remove_index :accounts, :name => 'index_accounts_an_ee'
277
+ #
278
+ # TABLES_TO_CONVERT.each do | table |
279
+ #
280
+ # remove_index table, :name => "index_#{ table }_id_end"
281
+ # remove_index table, :name => "index_#{ table }_id_start_end"
282
+ # remove_index table, :name => "index_#{ table }_start_end"
283
+ #
284
+ # execute "UPDATE #{ table } SET id = uuid"
285
+ #
286
+ # remove_column table, :uuid
287
+ # remove_column table, :effective_end
288
+ # remove_column table, :effective_start
289
+ #
290
+ # end
291
+ #
292
+ # # Add back any indexes you removed at the very start of "up", e.g.:
293
+ # #
294
+ # # add_index :accounts, :account_number, :unique => true
295
+ #
296
+ # end
297
+ # end
298
+ #
299
+ module ManuallyDated
300
+
301
+ # In order for indices to work properly on +effective_end+ dates, +NULL+
302
+ # values cannot be permitted as SQL +NULL+ is magic and means "has no
303
+ # value", so such a value in a column prohibits indexing.
304
+ #
305
+ # We might have used a +NULL+ value in the 'end' date to mean "this is
306
+ # the contemporary/current record", but since we can't do that, we need
307
+ # the rather nasty alternative of an agreed constant that defines a
308
+ # "large date" which represents "maximum possible end-of-time".
309
+ #
310
+ # SQL does not define a maximum date, but most implementations do.
311
+ # PostgreSQL has a very high maximum year, while SQLite, MS SQL Server
312
+ # and MySQL (following a cursory Google search for documentation) say
313
+ # that the end of year 9999 is as high as it goes.
314
+ #
315
+ # To use this +DATE_MAXIMUM+ constant in raw SQL, be sure to format the
316
+ # Time instance through your ActiveRecord database adapter thus:
317
+ #
318
+ # ActiveRecord::Base.connection.quoted_date( Hoodoo::ActiveRecord::ManuallyDated::DATE_MAXIMUM )
319
+ # # => returns "9999-12-31 23:59:59.000000" for PostgreSQL 9.4.
320
+ #
321
+ DATE_MAXIMUM = Time.parse( '9999-12-31T23:59:59.0Z' )
322
+
323
+ # Rounding resolution, in terms of number of decimal places to which
324
+ # seconds are rounded. Excessive accuracy makes for difficult, large
325
+ # indices in the database and may fall foul of system / database
326
+ # clock accuracy mismatches.
327
+ #
328
+ SECONDS_DECIMAL_PLACES = 2 # An Integer from 0 upwards
329
+
330
+ # Instantiates this module when it is included.
331
+ #
332
+ # Example:
333
+ #
334
+ # class SomeModel < ActiveRecord::Base
335
+ # include Hoodoo::ActiveRecord::ManuallyDated
336
+ # # ...
337
+ # end
338
+ #
339
+ # Depends upon and auto-includes Hoodoo::ActiveRecord::UUID and
340
+ # Hoodoo::ActiveRecord::Finder.
341
+ #
342
+ # +model+:: The ActiveRecord::Base descendant that is including
343
+ # this module.
344
+ #
345
+ def self.included( model )
346
+ model.class_attribute(
347
+ :nz_co_loyalty_hoodoo_manually_dated,
348
+ {
349
+ :instance_predicate => false,
350
+ :instance_accessor => false
351
+ }
352
+ )
353
+
354
+ unless model == Hoodoo::ActiveRecord::Base
355
+ model.send( :include, Hoodoo::ActiveRecord::UUID )
356
+ model.send( :include, Hoodoo::ActiveRecord::Finder )
357
+ instantiate( model )
358
+ end
359
+
360
+ super( model )
361
+ end
362
+
363
+ # When instantiated in an ActiveRecord::Base subclass, all of the
364
+ # Hoodoo::ActiveRecord::ManullyDated::ClassMethods methods are defined
365
+ # as class methods on the including class.
366
+ #
367
+ # +model+:: The ActiveRecord::Base descendant that is including
368
+ # this module.
369
+ #
370
+ def self.instantiate( model )
371
+ model.extend( ClassMethods )
372
+ end
373
+
374
+ # Collection of class methods that get defined on an including class via
375
+ # Hoodoo::ActiveRecord::ManuallyDated::included.
376
+ #
377
+ module ClassMethods
378
+
379
+ # Activate manually-driven historic dating for this model.
380
+ #
381
+ # See the module documentation for Hoodoo::ActiveRecord::ManuallyDated
382
+ # for full information on dating, column/attribute requirements and so
383
+ # forth.
384
+ #
385
+ # When dating is enabled, a +before_save+ filter will ensure that the
386
+ # record's +created_at+ and +updated_at+ fields are manually set to
387
+ # the current time ("now"), if not already set by the time the filter
388
+ # is run. The record's +effective_start+ time is set to match
389
+ # +created_at+ if not already set and +effective_end+ is set to
390
+ # Hoodoo::ActiveRecord::ManuallyDated::DATE_MAXIMUM _if_ not already
391
+ # set. The record's +uuid+ resource UUID is set to the value of the
392
+ # +id+ column if not already set, which is useful for new records but
393
+ # should never happen for history-savvy updates performed by this
394
+ # mixin's code.
395
+ #
396
+ def manual_dating_enabled
397
+ self.nz_co_loyalty_hoodoo_manually_dated = true
398
+
399
+ before_save do
400
+ now = Time.now.utc.round( SECONDS_DECIMAL_PLACES )
401
+
402
+ self.created_at ||= now
403
+ self.updated_at ||= now
404
+ self.effective_start ||= self.created_at
405
+ self.effective_end ||= DATE_MAXIMUM
406
+ end
407
+
408
+ # This is very similar to the UUID mixin, but works on the 'uuid'
409
+ # column. With manual dating, ActiveRecord's quirks with changing
410
+ # the primary key column, but still doing weird things with an
411
+ # attribute and accessor called "id", forces us to give up on any
412
+ # notion of changing the primary key. Keep "id" unique. This means
413
+ # the UUID mixin, if in use, is now setting the *real* per row
414
+ # unique key, while the "uuid" contains the UUID that should be
415
+ # rendered for the resource representation and will appear in more
416
+ # than one database row if the record has history entries. Thus,
417
+ # the validation is scoped to be unique only per "effective_end"
418
+ # value.
419
+ #
420
+ # Since the X-Resource-UUID header may be used and result in an
421
+ # attribute "id" being specified inbound for new records, we take
422
+ # any value of "id" if present and use that in preference to a
423
+ # totally new UUID in order to deal with that use case.
424
+
425
+ validate( :on => :create ) do
426
+ self.uuid ||= self.id || Hoodoo::UUID.generate()
427
+ end
428
+
429
+ validates(
430
+ :uuid,
431
+ {
432
+ :uuid => true,
433
+ :presence => true,
434
+ :uniqueness => { :scope => :effective_end },
435
+ }
436
+ )
437
+
438
+ # Lastly, we must specify an acquisition scope that's based on
439
+ # the "uuid" column only and *not* the "id" column.
440
+
441
+ acquire_with_id_substitute( :uuid )
442
+
443
+ end
444
+
445
+ # If a prior call has been made to #manual_dating_enabled then this
446
+ # method returns +true+, else +false+.
447
+ #
448
+ def manual_dating_enabled?
449
+ return self.nz_co_loyalty_hoodoo_manually_dated == true
450
+ end
451
+
452
+ # Return an ActiveRecord::Relation instance which only matches records
453
+ # that are relevant/effective at the date/time in the value of
454
+ # +context.request.dated_at+ within the given +context+. If this value
455
+ # is +nil+ then the current time in UTC is used.
456
+ #
457
+ # Manual historic dating must have been previously activated through a
458
+ # call to #dating_enabled, else results will be undefined.
459
+ #
460
+ # +context+:: Hoodoo::Services::Context instance describing a call
461
+ # context. This is typically a value passed to one of
462
+ # the Hoodoo::Services::Implementation instance methods
463
+ # that a resource subclass implements.
464
+ #
465
+ def manually_dated( context )
466
+ date_time = context.request.dated_at || Time.now
467
+ return self.manually_dated_at( date_time )
468
+ end
469
+
470
+ # Return an ActiveRecord::Relation instance which only matches records
471
+ # that are relevant/effective at the given date/time. If this value is
472
+ # +nil+ then the current time in UTC is used.
473
+ #
474
+ # Manual historic dating must have been previously activated through a
475
+ # call to #dating_enabled, else results will be undefined.
476
+ #
477
+ # +date_time+:: (Optional) A Time or DateTime instance, or a String that
478
+ # can be converted to a DateTime instance, for which the
479
+ # "effective dated" scope is to be constructed.
480
+ #
481
+ def manually_dated_at( date_time = Time.now )
482
+ date_time = date_time.to_time.utc.round( SECONDS_DECIMAL_PLACES )
483
+
484
+ arel_table = self.arel_table()
485
+ arel_query = arel_table[ :effective_start ].lteq( date_time ).
486
+ and(
487
+ arel_table[ :effective_end ].gt( date_time )
488
+ # .or(
489
+ # arel_table[ :effective_end ].eq( nil )
490
+ # )
491
+ )
492
+
493
+ where( arel_query )
494
+ end
495
+
496
+ # Return an ActiveRecord::Relation instance which only matches records
497
+ # that are from the past. The 'current' record for any given UUID will
498
+ # never be included by the scope.
499
+ #
500
+ # Manual historic dating must have been previously activated through a
501
+ # call to #dating_enabled, else results will be undefined.
502
+ #
503
+ def manually_dated_historic
504
+ where.not( :effective_end => DATE_MAXIMUM )
505
+ end
506
+
507
+ # Return an ActiveRecord::Relation instance which only matches records
508
+ # that are 'current'. The historic/past records for any given UUID
509
+ # will never be included in the scope.
510
+ #
511
+ # Manual historic dating must have been previously activated through a
512
+ # call to #dating_enabled, else results will be undefined.
513
+ #
514
+ def manually_dated_contemporary
515
+ where( :effective_end => DATE_MAXIMUM )
516
+ end
517
+
518
+ # Update a record with manual historic dating. This means that the
519
+ # 'current' / most recent record is turned into a historic entry via
520
+ # setting its +effective_end+ date, a duplicate is made and any new
521
+ # attribute values are set in this duplicate. This new record is then
522
+ # saved as the 'current' version. A transaction containing a database
523
+ # lock over all history rows for the record via its UUID (+id+ column)
524
+ # is used to provide concurrent access safety.
525
+ #
526
+ # The return value is complex:
527
+ #
528
+ # * If +nil+, the record that was to be updated could not be found.
529
+ # * If not +nil+, an ActiveRecord model instance is returned. This is
530
+ # the new 'current' record, but it might not be saved; validation
531
+ # errors may have happened. You need to check for this before
532
+ # proceeding. This will _not_ be the same model instance found for
533
+ # the original, most recent / current record.
534
+ #
535
+ # If attempts to update the previous, now-historic record's effective
536
+ # end date fail, an exception may be thrown as the failure condition
537
+ # is unexpected (it will almost certainly be because of a database
538
+ # connection failure). You _might_ need to call this method from a
539
+ # block with a +rescue+ clause if you wish to handle those elegantly,
540
+ # but it is probably a serious failure and the generally recommended
541
+ # behaviour is to just let Hoodoo's default exception handler catch
542
+ # the exception and return an HTTP 500 response to the API caller.
543
+ #
544
+ # _Unnamed_ parameters are:
545
+ #
546
+ # +context+:: Hoodoo::Services::Context instance describing a call
547
+ # context. This is typically a value passed to one of
548
+ # the Hoodoo::Services::Implementation instance methods
549
+ # that a resource subclass implements. This is used to
550
+ # find the record's UUID and new attribute information
551
+ # unless overridden (see named parameter list).
552
+ #
553
+ # Additional _named_ parameters are:
554
+ #
555
+ # +ident+:: UUID (32-digit +id+ column value) of the record to be
556
+ # updated. If omitted, +context.request.ident+ is used.
557
+ #
558
+ # +attributes+:: Hash of attributes to write (via ActiveRecord's
559
+ # +assign_attributes+ method) in order to perform the
560
+ # update. If omitted, +context.request.body+ is used.
561
+ #
562
+ # +scope+:: ActiveRecord::Relation instance providing the scope
563
+ # to use for database locks and acquiring the record
564
+ # to update. Defaults to #acquisition_scope for the
565
+ # prevailing +ident+ value.
566
+ #
567
+ def manually_dated_update_in( context,
568
+ ident: context.request.ident,
569
+ attributes: context.request.body,
570
+ scope: all() )
571
+
572
+ new_record = nil
573
+ retry_operation = false
574
+
575
+ begin
576
+ begin
577
+
578
+ # 'requires_new' => exceptions in nested transactions will cause
579
+ # rollback; see the comment documentation for the Writer module's
580
+ # "persist_in" method for details.
581
+ #
582
+ self.transaction( :requires_new => true ) do
583
+
584
+ lock_scope = scope.acquisition_scope( ident ).lock( true )
585
+ self.connection.execute( lock_scope.to_sql )
586
+
587
+ original = scope.manually_dated_contemporary().acquire( ident )
588
+ break if original.nil?
589
+
590
+ # The only way this can fail is by throwing an exception.
591
+ #
592
+ original.update_column( :effective_end, Time.now.utc.round( SECONDS_DECIMAL_PLACES ) )
593
+
594
+ # When you 'dup' a live model, ActiveRecord clears the 'created_at'
595
+ # and 'updated_at' values, and the 'id' column - even if you set
596
+ # the "primary_key=..." value on the model to something else. Put
597
+ # it all back together again.
598
+ #
599
+ # Duplicate, apply attributes, then overwrite anything that is
600
+ # vital for dating so that the inbound attributes hash can't cause
601
+ # any inconsistencies.
602
+ #
603
+ new_record = original.dup
604
+ new_record.assign_attributes( attributes )
605
+
606
+ new_record.id = nil
607
+ new_record.uuid = original.uuid
608
+ new_record.created_at = original.created_at
609
+ new_record.updated_at = original.effective_end # (sic.)
610
+ new_record.effective_start = original.effective_end # (sic.)
611
+ new_record.effective_end = DATE_MAXIMUM
612
+
613
+ # Save with validation but no exceptions. The caller examines the
614
+ # returned object to see if there were any validation errors.
615
+ #
616
+ new_record.save()
617
+
618
+ # Must roll back if the new record didn't save, to undo the
619
+ # 'effective_end' column update on 'original' earlier.
620
+ #
621
+ raise ::ActiveRecord::Rollback if new_record.errors.present?
622
+ end
623
+
624
+ retry_operation = false
625
+
626
+ rescue ::ActiveRecord::StatementInvalid => exception
627
+
628
+ # By observation, PostgreSQL can start worrying about deadlocks
629
+ # with the above. TODO: I don't know why; I can't see how it can
630
+ # possibly end up trying to do the things the logs imply given
631
+ # that the locking is definitely working and blocking anything
632
+ # other than one transaction at a time from working on a set of
633
+ # rows scoped by a particular resource UUID.
634
+ #
635
+ # In such a case, retry. But only do so once; then give up.
636
+ #
637
+ if retry_operation == false && exception.message.downcase.include?( 'deadlock' )
638
+ retry_operation = true
639
+
640
+ # Give other Threads time to run, maximising chance of deadlock
641
+ # being resolved before retry.
642
+ #
643
+ sleep 0.1
644
+
645
+ else
646
+ raise exception
647
+
648
+ end
649
+
650
+ end # "begin"..."rescue"..."end"
651
+ end while ( retry_operation ) # "begin"..."end while"
652
+
653
+ return new_record
654
+ end
655
+
656
+ # Analogous to #manually_dated_update_in and with the same return
657
+ # value and exception generation semantics, so see that method for
658
+ # those details.
659
+ #
660
+ # This particular method soft-deletes a record. It moves the 'current'
661
+ # entry to being an 'historic' entry as in #manually_dated_update_in,
662
+ # but does not then generate any new 'current' record. Returns +nil+
663
+ # if the record couldn't be found to start with, else returns the
664
+ # found and soft-deleted / now-historic model instance.
665
+ #
666
+ # Since no actual "hard" record deletion takes place, traditional
667
+ # ActiveRecord concerns of +delete+ versus +destroy+ or of dependency
668
+ # chain destruction do not apply. No callbacks or validations are run
669
+ # when the record is updated (via ActiveRecord's #update_column). A
670
+ # failure to update the record will result in an unhandled exception.
671
+ # No change is made to the +updated_at+ column value.
672
+ #
673
+ # _Unnamed_ parameters are:
674
+ #
675
+ # +context+:: Hoodoo::Services::Context instance describing a call
676
+ # context. This is typically a value passed to one of
677
+ # the Hoodoo::Services::Implementation instance methods
678
+ # that a resource subclass implements. This is used to
679
+ # obtain the record's UUID unless overridden (see named
680
+ # parameter list).
681
+ #
682
+ # Additional _named_ parameters are:
683
+ #
684
+ # +ident+:: UUID (32-digit +id+ column value) of the record to be
685
+ # updated. If omitted, +context.request.ident+ is used.
686
+ #
687
+ # +scope+:: ActiveRecord::Relation instance providing the scope
688
+ # to use for database locks and acquiring the record
689
+ # to update. Defaults to #acquisition_scope for the
690
+ # prevailing +ident+ value.
691
+ #
692
+ def manually_dated_destruction_in( context,
693
+ ident: context.request.ident,
694
+ scope: all() )
695
+
696
+ # See #manually_dated_update_in implementation for rationale.
697
+ #
698
+ return self.transaction do
699
+
700
+ record = scope.manually_dated_contemporary().lock( true ).acquire( ident )
701
+ record.update_column( :effective_end, Time.now.utc ) unless record.nil?
702
+ record
703
+
704
+ end
705
+ end
706
+
707
+ end
708
+ end
709
+ end
710
+ end