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 +8 -8
- data/lib/hoodoo/active.rb +1 -0
- data/lib/hoodoo/active/active_record/base.rb +7 -0
- data/lib/hoodoo/active/active_record/creator.rb +1 -1
- data/lib/hoodoo/active/active_record/dated.rb +8 -1
- data/lib/hoodoo/active/active_record/finder.rb +37 -3
- data/lib/hoodoo/active/active_record/manually_dated.rb +710 -0
- data/lib/hoodoo/active/active_record/secure.rb +1 -1
- data/lib/hoodoo/active/active_record/support.rb +8 -2
- data/lib/hoodoo/active/active_record/translated.rb +1 -1
- data/lib/hoodoo/active/active_record/uuid.rb +27 -3
- data/lib/hoodoo/active/active_record/writer.rb +1 -1
- data/lib/hoodoo/utilities/uuid.rb +13 -9
- data/lib/hoodoo/version.rb +1 -1
- data/spec/active/active_record/dated_spec.rb +24 -6
- data/spec/active/active_record/finder_spec.rb +26 -4
- data/spec/active/active_record/manually_dated_spec.rb +776 -0
- data/spec/spec_helper.rb +2 -1
- data/spec/utilities/uuid_spec.rb +142 -10
- metadata +5 -16
checksums.yaml
CHANGED
@@ -1,15 +1,15 @@
|
|
1
1
|
---
|
2
2
|
!binary "U0hBMQ==":
|
3
3
|
metadata.gz: !binary |-
|
4
|
-
|
4
|
+
MjI0N2Q4YmRmMTIyYzA5NDhiZTdmYjY3YjBiNDg2ZWI4ZTQ5ODU2MQ==
|
5
5
|
data.tar.gz: !binary |-
|
6
|
-
|
6
|
+
M2U5ZjY5OTEyZWI4Y2NiN2M5M2VjM2MyNzYyZWQyNGQ3YzdmZThkZQ==
|
7
7
|
SHA512:
|
8
8
|
metadata.gz: !binary |-
|
9
|
-
|
10
|
-
|
11
|
-
|
9
|
+
Mjc4MGE2ZjMyMDNkNGMzYTViNzM5YmEyOTQ1M2M1ZTgyZGYwNjA4N2M1ZThl
|
10
|
+
ODNkOTZjNTcwNjQyNmY2OGRmMmRlOTE5ZTEyZTljYTg3MTI5MjZlNmIzYzY2
|
11
|
+
NTUwZWQ0ZmVjZDgxNzRkYzRmZWQ0YTAwM2U2MzE2NWNiMTNmYjI=
|
12
12
|
data.tar.gz: !binary |-
|
13
|
-
|
14
|
-
|
15
|
-
|
13
|
+
MjY2N2QwNjViZjhiYjM1NDBkNDczMGVkNDVlOTczYzVlOTRlYzM2NGQ4ODEx
|
14
|
+
NDFkNDI4YjI1NmYzZTVlNmNmYjdiMDhlZWU1NDk1YTRhNjg1YzE0YjNmZDY2
|
15
|
+
ZmM5NWM5MTc5OTEyOGQzN2I4MTUwMDE2NTRlNGQ5NTQ3MWVjYjY=
|
data/lib/hoodoo/active.rb
CHANGED
@@ -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
|
|
@@ -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 )
|
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
|