draft_approve 0.1.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.
@@ -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