hoodoo 1.1.3 → 1.2.0

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