draft_approve 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,16 @@
1
+ require 'draft_approve/serialization/json/draft_changes_proxy'
2
+ require 'draft_approve/serialization/json/serializer'
3
+
4
+ module DraftApprove
5
+ module Serialization
6
+ module Json
7
+ def self.get_serializer
8
+ DraftApprove::Serialization::Json::Serializer
9
+ end
10
+
11
+ def self.get_draft_changes_proxy
12
+ DraftApprove::Serialization::Json::DraftChangesProxy
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,21 @@
1
+ module DraftApprove
2
+ module Serialization
3
+ module Json
4
+ # Constants defining the keys used to point to associations when
5
+ # serializing draft changes.
6
+ #
7
+ # These work in a similar manner to how ActiveRecord polymorphic
8
+ # associations work, defining the type / class of the associated object,
9
+ # and it's id.
10
+ #
11
+ # IMPORTANT NOTE: These constants are written to the database, so cannot
12
+ # be updated without requiring a migration of existing draft data. Such a
13
+ # migration may be very slow, since these constants are embedded in the
14
+ # JSON generated by this serializer!
15
+ module Constants
16
+ TYPE = 'type'.freeze
17
+ ID = 'id'.freeze
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,317 @@
1
+ require 'draft_approve/draft_changes_proxy'
2
+ require 'draft_approve/serialization/json/constants'
3
+
4
+ module DraftApprove
5
+ module Serialization
6
+ module Json
7
+
8
+ # Json implementation of +DraftApproveProxy+. Clients should not need to
9
+ # worry about the specific implementation details of this class, and
10
+ # should refer to the +DraftApprove::DraftChangesProxy+ module details of
11
+ # the public API.
12
+ #
13
+ # It is often most convenient to use the
14
+ # +DraftTransaction#draft_proxy_for+ method to construct a
15
+ # +DraftApproveProxy+ instance. This will ensure the correct
16
+ # implementation of +DraftApproveProxy+ is used.
17
+ #
18
+ # @api private
19
+ #
20
+ # @see DraftApprove::DraftChangesProxy
21
+ # @see DraftTransaction#draft_proxy_for
22
+ class DraftChangesProxy
23
+ include DraftApprove::DraftChangesProxy
24
+ include Comparable
25
+
26
+ # The new, drafted value for the given attribute on the proxied +Draft+
27
+ # or draftable object. If no changes have been drafted for the given
28
+ # attribute, then returns the currently persisted value for the
29
+ # attribute.
30
+ #
31
+ # @param attribute_name [String]
32
+ #
33
+ # @return [Object, nil] the new value of the given attribute, or the
34
+ # currently persisted value if there are no draft changes for the
35
+ # attribute
36
+ #
37
+ # @see DraftApprove::DraftChangesProxy#new_value
38
+ def new_value(attribute_name)
39
+ # Create hash with default block for auto-memoization
40
+ @new_values_memo ||= Hash.new do |hash, attribute|
41
+ hash[attribute] = begin
42
+ association = @draftable_class.reflect_on_association(attribute)
43
+ if association.blank?
44
+ new_value_simple_attribute(attribute)
45
+ elsif association.belongs_to?
46
+ new_value_belongs_to_assocation(attribute)
47
+ else
48
+ new_value_non_belongs_to_assocation(attribute)
49
+ end
50
+ end
51
+ end
52
+
53
+ # Get memoized value, or calculate and store it
54
+ @new_values_memo[attribute_name.to_s]
55
+ end
56
+
57
+ # Whether any changes will occur to the given association of the proxied
58
+ # +Draft+ or draftable object.
59
+ #
60
+ # @param association_name [String]
61
+ #
62
+ # @return [Boolean] +true+ if any objects will be added to this
63
+ # association, removed from this association, or existing associations
64
+ # changed in any way. +false+ otherwise.
65
+ #
66
+ # @see DraftApprove::DraftChangesProxy#association_changed?
67
+ def association_changed?(association_name)
68
+ # Create hash with default block for auto-memoization
69
+ @association_changed_memo ||= Hash.new do |hash, association_name|
70
+ hash[association_name] = begin
71
+ (
72
+ associations_added(association_name).present? ||
73
+ associations_updated(association_name).present? ||
74
+ associations_removed(association_name).present?
75
+ )
76
+ end
77
+ end
78
+
79
+ @association_changed_memo[association_name.to_s]
80
+ end
81
+
82
+ # All associated objects which will be added to the given association of
83
+ # the proxied +Draft+ or draftable object.
84
+ #
85
+ # @param association_name [String]
86
+ #
87
+ # @return [Array<DraftChangesProxy>] DraftChangesProxy objects for each
88
+ # object which will be added to the given association
89
+ #
90
+ # @see DraftApprove::DraftChangesProxy#associations_added
91
+ def associations_added(association_name)
92
+ @associations_added_memo ||= Hash.new do |hash, association_name|
93
+ hash[association_name] = association_values(association_name, :created)
94
+ end
95
+
96
+ @associations_added_memo[association_name.to_s]
97
+ end
98
+
99
+ # All associated objects which have been updated, but remain
100
+ # the proxied +Draft+ or draftable object.
101
+ #
102
+ # @param association_name [String]
103
+ #
104
+ # @return [Array<DraftChangesProxy>] DraftChangesProxy objects for each
105
+ # object which will be added to the given association
106
+ #
107
+ # @see DraftApprove::DraftChangesProxy#associations_updated
108
+ def associations_updated(association_name)
109
+ @associations_updated_memo ||= Hash.new do |hash, association_name|
110
+ hash[association_name] = association_values(association_name, :updated)
111
+ end
112
+
113
+ @associations_updated_memo[association_name.to_s]
114
+ end
115
+
116
+ # All associated objects which will be removed from the given
117
+ # association of the proxied +Draft+ or draftable object.
118
+ #
119
+ # @param association_name [String]
120
+ #
121
+ # @return [Array<DraftChangesProxy>] DraftChangesProxy objects for each
122
+ # object which will be removed from the given association
123
+ #
124
+ # @see DraftApprove::DraftChangesProxy#associations_removed
125
+ def associations_removed(association_name)
126
+ @associations_removed_memo ||= Hash.new do |hash, association_name|
127
+ hash[association_name] = association_values(association_name, :deleted)
128
+ end
129
+
130
+ @associations_removed_memo[association_name.to_s]
131
+ end
132
+
133
+ # Override comparable for +DraftChangesProxy+ objects. This is so
134
+ # operators such as <tt>+</tt> and <tt>-</tt> work accurately when an
135
+ # array of +DraftChangesProxy+ objects are being returned. It also makes
136
+ # testing easier.
137
+ #
138
+ # @return [Integer] 0 if the given object is a +DraftChangesProxy+
139
+ # which refers to the same +Draft+ (if any), the same draftable
140
+ # (if any), the same draftable class, and the same +DraftTransaction+.
141
+ # Non-zero otherwise.
142
+ def <=>(other)
143
+ return -1 unless other.is_a?(self.class)
144
+
145
+ [:draft, :draftable, :draftable_class, :draft_transaction].each do |method|
146
+ comp = self.public_send(method) <=> other.public_send(method)
147
+ return -1 if comp.nil?
148
+ return comp unless comp.zero?
149
+ end
150
+
151
+ # Checked all attributes, and all are equal
152
+ return 0
153
+ end
154
+
155
+ alias :eql? :==
156
+
157
+ # Override hash for +DraftChangesProxy+ objects. This is so operators
158
+ # such as + and - work accurately when an array of +DraftChangesProxy+
159
+ # objects are being returned. It also makes testing easier.
160
+ #
161
+ # @return [Integer] a hash of all the +DraftChangeProxy+s attributes
162
+ def hash
163
+ [@draft, @draftable, @draftable_class, @draft_transaction].hash
164
+ end
165
+
166
+ private
167
+
168
+ # Helper to get the new value of a simple attribute as a result of this
169
+ # draft transaction
170
+ def new_value_simple_attribute(attribute_name)
171
+ attribute_name = attribute_name.to_s
172
+
173
+ # This attribute is a simple value (not an association)
174
+ if @draft.blank? || !@draft.draft_changes.has_key?(attribute_name)
175
+ # Either no draft, or no changes for this attribute
176
+ return draft_proxy_for(@draftable.public_send(attribute_name))
177
+ else
178
+ # Draft changes have been made on this attribute...
179
+ new_value = @draft.draft_changes[attribute_name][1]
180
+ return draft_proxy_for(new_value)
181
+ end
182
+ end
183
+
184
+ # Helper to get the new value of a belongs_to association as a result
185
+ # of this draft transaction
186
+ def new_value_belongs_to_assocation(attribute_name)
187
+ attribute_name = attribute_name.to_s
188
+
189
+ # This attribute is an association where the 'belongs_to' is on this
190
+ # class...
191
+ if @draft.blank? || !@draft.draft_changes.has_key?(attribute_name)
192
+ # Either no draft, or no changes for this attribute
193
+ return draft_proxy_for(@draftable.public_send(attribute_name))
194
+ else
195
+ # Draft changes have been made on this attribute...
196
+ new_value = @draft.draft_changes[attribute_name][1]
197
+
198
+ if new_value.blank?
199
+ return nil # The association link has been removed on the draft
200
+ else
201
+ new_value_class = Object.const_get(new_value[Constants::TYPE])
202
+ new_value_object = new_value_class.find(new_value[Constants::ID])
203
+ return draft_proxy_for(new_value_object)
204
+ end
205
+ end
206
+ end
207
+
208
+ # Helper to get the new value of has_one / has_many associations (ie.
209
+ # get all the objects the association would return after this draft
210
+ # dransaction has been applied)
211
+ def new_value_non_belongs_to_assocation(association_name)
212
+ association_name = association_name.to_s
213
+
214
+ associated_instances = []
215
+
216
+ # Starting point is all objects already associated
217
+ associated_instances += current_value(association_name)
218
+
219
+ # Add any new objects which will be created by this transaction and
220
+ # refer to this object
221
+ associated_instances += associations_added(association_name)
222
+
223
+ # Finally remove any associations which will be deleted or not
224
+ # refer to this object anymore as a reuslt of this transaction
225
+ associated_instances -= associations_removed(association_name)
226
+
227
+ return associated_instances
228
+ end
229
+
230
+ # Helper to get the associations which have been created or deleted as
231
+ # a result of this draft transaction
232
+ def association_values(association_name, mode)
233
+ association_name = association_name.to_s
234
+
235
+ association = @draftable_class.reflect_on_association(association_name)
236
+ if association.blank? || association.belongs_to?
237
+ raise(ArgumentError, "#{association_name} must be a has_many or has_one association")
238
+ end
239
+
240
+ associated_class_name = association.class_name
241
+ associated_attribute_name = association.inverse_of.name
242
+
243
+ # If we are proxying a concrete object, all associations will point
244
+ # directly at it, otherwise we are proxying a CREATE draft and
245
+ # associations will point at the draft
246
+ required_object = (@draftable.present? ? @draftable : @draft)
247
+
248
+ case mode
249
+ when :created
250
+ # Looking for newly created associations, so we want to find
251
+ # objects where the new value (index=1) of the associated
252
+ # attribute points at this object.
253
+ # eg. if looking for new memberships for Person 1, we want to find
254
+ # Membership objects where the json changes look like this:
255
+ # { "person" => [nil, { "TYPE" => "Person", "ID" => 1 }] }
256
+ json_query_str_type = "{#{associated_attribute_name},1,#{Constants::TYPE}}"
257
+ json_query_str_id = "{#{associated_attribute_name},1,#{Constants::ID}}"
258
+
259
+ created_associations = @draft_transaction.drafts.where(
260
+ draftable_type: associated_class_name
261
+ ).where(
262
+ <<~SQL
263
+ draft_changes #>> '#{json_query_str_type}' = '#{required_object.class.name}'
264
+ AND
265
+ draft_changes #>> '#{json_query_str_id}' = '#{required_object.id}'
266
+ SQL
267
+ )
268
+
269
+ return draft_proxy_for(created_associations)
270
+ when :updated
271
+ # Looking for associations which have draft updates but which still
272
+ # point at this object (if it didn't previously point at this object
273
+ # the change is a new association, so covered by the association
274
+ # created case - if it no longer points at this object the
275
+ # association has been broken, so covered by the association deleted
276
+ # case)
277
+ required_proxy = draft_proxy_for(required_object)
278
+
279
+ updated_associations = current_value(association_name).select do |proxy|
280
+ proxy.changed? &&
281
+ proxy.new_value(associated_attribute_name) == required_proxy
282
+ end
283
+
284
+ return draft_proxy_for(updated_associations)
285
+ when :deleted
286
+ # Looking for current associations which have either been drafted
287
+ # for complete deletion, or which have had their reference changed
288
+ # to no longer point at this object
289
+ required_proxy = draft_proxy_for(required_object)
290
+
291
+ deleted_associations = current_value(association_name).select do |proxy|
292
+ proxy.delete? ||
293
+ proxy.new_value(associated_attribute_name) != required_proxy
294
+ end
295
+
296
+ return draft_proxy_for(deleted_associations)
297
+ else
298
+ raise(ArgumentError, "Unrecognised mode #{mode}")
299
+ end
300
+ end
301
+
302
+ # Helper to get a draft proxy for any object before returning it
303
+ def draft_proxy_for(object)
304
+ if object.respond_to?(:map)
305
+ # Object is a collection (likely an ActiveRecord collection), recursively call draft_proxy_for
306
+ return object.map { |o| draft_proxy_for(o) }
307
+ elsif (object.is_a? Draft) || (object.respond_to?(:draftable?) && object.draftable?)
308
+ # Object is a draft or a draftable, wrap it in a DraftChangesProxy
309
+ return self.class.new(object, @draft_transaction)
310
+ else
311
+ return object
312
+ end
313
+ end
314
+ end
315
+ end
316
+ end
317
+ end
@@ -0,0 +1,181 @@
1
+ require 'draft_approve/serialization/json/constants'
2
+
3
+ module DraftApprove
4
+ module Serialization
5
+ module Json
6
+
7
+ # Logic for serializing changes to ActiveRecord models into JSON format,
8
+ # and deserializing the changes on a +Draft+ object into the new values
9
+ # for an ActiveRecord model.
10
+ #
11
+ # @api private
12
+ class Serializer
13
+
14
+ # Serialize changes on an ActiveRecord model into a JSON representation
15
+ # of the changes.
16
+ #
17
+ # @param model [Object] the +acts_as_draftable+ ActiveRecord model whose
18
+ # changes will be serialized to JSON
19
+ #
20
+ # @return [Hash] a hash representation of the changes to the given
21
+ # model, which can be automatically converted to JSON if persisted to
22
+ # a JSON formatted database column
23
+ def self.changes_for_model(model)
24
+ JsonSerializer.new(model).changes_for_model
25
+ end
26
+
27
+ # Deserialize changes from a +Draft+ object into the new values for the
28
+ # +acts_as_draftable+ ActiveRecord model the draft relates to.
29
+ #
30
+ # @param draft [Draft] the +Draft+ whose changes will be deserialized
31
+ #
32
+ # @return [Hash] a hash representation of the new values for the object
33
+ # the given draft relates to.
34
+ #
35
+ # @example
36
+ # draft = Person.new(name: 'Joe Bloggs').save_draft!
37
+ # Json::Serializer.new_values_for_draft(draft)
38
+ # #=> { "name" => [nil, "Joe Bloggs"] }
39
+ def self.new_values_for_draft(draft)
40
+ JsonDeserializer.new(draft).new_values_for_draft
41
+ end
42
+
43
+ # Private Inner Class to contain the JSON Serialization logic
44
+ class JsonSerializer
45
+ def initialize(model)
46
+ @model = model
47
+ end
48
+
49
+ def changes_for_model
50
+ changes = {}
51
+ @model.class.reflect_on_all_associations(:belongs_to).each do |belongs_to_assoc|
52
+ changes.merge!(association_change(belongs_to_assoc))
53
+ end
54
+ return changes.merge!(non_association_changes)
55
+ end
56
+
57
+ private
58
+
59
+ def association_change(association)
60
+ old_value = association_old_value(association)
61
+ new_value = association_new_value(association)
62
+
63
+ if old_value == new_value
64
+ return {}
65
+ else
66
+ return { association.name.to_s => [old_value, new_value] }
67
+ end
68
+ end
69
+
70
+ def non_association_changes
71
+ association_attribute_names = @model.class.reflect_on_all_associations(:belongs_to).map do |ref|
72
+ [ref.foreign_type, ref.foreign_key, ref.association_foreign_key]
73
+ end.flatten.uniq.compact
74
+
75
+ non_association_attribute_names = @model.attribute_names - association_attribute_names
76
+
77
+ return non_association_attribute_names.each_with_object({}) do |attribute_name, result_hash|
78
+ if @model.public_send("#{attribute_name}_changed?")
79
+ result_hash[attribute_name] = @model.public_send("#{attribute_name}_change")
80
+ end
81
+ end
82
+ end
83
+
84
+ # The old value of an association must be nil or point to a persisted
85
+ # non-draft object.
86
+ def association_old_value(association)
87
+ if association.polymorphic?
88
+ old_type = @model.public_send("#{association.foreign_type}_was")
89
+ old_id = @model.public_send("#{association.foreign_key}_was")
90
+ else
91
+ old_type = association.class_name
92
+ old_id = @model.public_send("#{association.foreign_key}_was")
93
+ end
94
+
95
+ return nil if old_id.blank? || old_type.blank?
96
+ return { Constants::TYPE => old_type, Constants::ID => old_id }
97
+ end
98
+
99
+ # The new value of an association may be nil, or point to a persisted
100
+ # model, or point to a non-persisted model with a persisted draft.
101
+ #
102
+ # Note that if the associated object is not persisted, and has no
103
+ # persisted draft, then this is an error scenario.
104
+ def association_new_value(association)
105
+ associated_obj = @model.public_send(association.name)
106
+
107
+ if associated_obj.blank?
108
+ return nil
109
+ elsif associated_obj.persisted?
110
+ if association.polymorphic?
111
+ return {
112
+ Constants::TYPE => @model.public_send(association.foreign_type),
113
+ Constants::ID => @model.public_send(association.foreign_key)
114
+ }
115
+ else
116
+ return {
117
+ Constants::TYPE => association.class_name,
118
+ Constants::ID => @model.public_send(association.foreign_key)
119
+ }
120
+ end
121
+ else # associated_obj not persisted - so we need a persisted draft
122
+ draft = associated_obj.draft_pending_approval
123
+
124
+ if draft.blank? || draft.new_record?
125
+ raise(DraftApprove::Errors::AssociationUnsavedError, "#{association.name} points to an unsaved object")
126
+ end
127
+
128
+ return { Constants::TYPE => draft.class.name, Constants::ID => draft.id }
129
+ end
130
+ end
131
+ end
132
+
133
+ # Private Inner Class to contain the JSON Serialization logic
134
+ class JsonDeserializer
135
+ def initialize(draft)
136
+ @draft = draft
137
+ end
138
+
139
+ def new_values_for_draft
140
+ draftable_class = Object.const_get(@draft.draftable_type)
141
+ association_attribute_names = draftable_class.reflect_on_all_associations(:belongs_to).map(&:name).map(&:to_s)
142
+
143
+ return @draft.draft_changes.each_with_object({}) do |(attribute_name, change), result_hash|
144
+ new_value = change[1]
145
+ if association_attribute_names.include?(attribute_name)
146
+ result_hash[attribute_name] = associated_model_for_new_value(new_value)
147
+ else
148
+ result_hash[attribute_name] = new_value
149
+ end
150
+ end
151
+ end
152
+
153
+ private
154
+
155
+ def associated_model_for_new_value(new_value)
156
+ return nil if new_value.nil?
157
+
158
+ associated_model_type = new_value[Constants::TYPE]
159
+ associated_model_id = new_value[Constants::ID]
160
+
161
+ associated_class = Object.const_get(associated_model_type)
162
+
163
+ if associated_class.ancestors.include? Draft
164
+ # The associated class is a draft (or subclass).
165
+ # It must be in the same draft transaction as the draft we're getting values for.
166
+ associated_draft = @draft.draft_transaction.drafts.find(associated_model_id)
167
+
168
+ raise(DraftApprove::Errors::PriorDraftNotAppliedError) if associated_draft.draftable.nil?
169
+
170
+ return associated_draft.draftable
171
+ else
172
+ return associated_class.find(associated_model_id)
173
+ end
174
+ end
175
+ end
176
+
177
+ private_constant :JsonSerializer, :JsonDeserializer
178
+ end
179
+ end
180
+ end
181
+ end