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