activejsonmodel 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,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveJsonModel
4
+ # Instance of an attribute for a model backed by JSON persistence. Data object
5
+ # used for tracking the attributes on the models.
6
+ #
7
+ # e.g.
8
+ # class class Credentials < ::ActiveJsonModel
9
+ # json_attribute :username
10
+ # json_attribute :password
11
+ # end
12
+ #
13
+ # #...
14
+ #
15
+ # # Returns instances of JsonAttribute
16
+ # Credentials.active_json_model_attributes
17
+ class JsonAttribute
18
+ attr_reader :name
19
+ attr_reader :clazz
20
+ attr_reader :default
21
+ attr_reader :render_default
22
+ attr_reader :validation
23
+ attr_reader :load_proc
24
+ attr_reader :dump_proc
25
+
26
+ # Creates a record of a JSON-backed attribute
27
+ #
28
+ # @param name [Symbol, String] the name of the attribute
29
+ # @param clazz [Class] the Class that implements the type of the attribute (ActiveJsonModel)
30
+ # @param default [Object, ...] the default value for the attribute if unspecified
31
+ # @param validation [Hash] an object with properties that represent ActiveModel validation
32
+ # @param dump_proc [Proc] proc to generate a value from the value to be rendered to JSON. Given <code>value</code>
33
+ # and <code>parent_model</code> values. The value returned is assumed to be a valid JSON value. The proc
34
+ # can take either one or two parameters this is automatically handled by the caller.
35
+ # @param load_proc [Proc] proc to generate a value from the JSON. May take <code>json_value</code> and
36
+ # <code>json_hash</code>. The raw value and the parent hash being parsed, respectively. May return either
37
+ # a class (which will be instantiated) or a value directly. The proc can take either one or two parameters
38
+ # this is automatically handled by the caller.
39
+ # @param render_default [Boolean] should the default value be rendered to JSON? Default is true. Note this only
40
+ # applies if the value has not be explicitly set. If explicitly set, the value renders, regardless of if
41
+ # the value is the same as the default value.
42
+ def initialize(name:, clazz:, default:, validation:, dump_proc:, load_proc:, render_default: true)
43
+ @name = name.to_sym
44
+ @clazz = clazz
45
+ @default = default
46
+ @render_default = render_default
47
+ @validation = validation
48
+ @dump_proc = dump_proc
49
+ @load_proc = load_proc
50
+ end
51
+
52
+ # Get a default value for this attribute. Handles defaults that can be generators with callbacks and proper
53
+ # cloning of real values to avoid cross-object mutation.
54
+ def get_default_value
55
+ if default
56
+ if default.respond_to?(:call)
57
+ default.call
58
+ else
59
+ default.clone
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,597 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support'
4
+ require_relative './json_attribute'
5
+ require_relative './after_load_callback'
6
+
7
+ if defined?(::ActiveRecord)
8
+ require_relative './active_record_type'
9
+ require_relative './active_record_encrypted_type'
10
+ end
11
+
12
+ module ActiveJsonModel
13
+ module Model
14
+ def self.included(base_class)
15
+ # Add all the class methods to the included class
16
+ base_class.extend(ClassMethods)
17
+
18
+ # Add additional settings into the class
19
+ base_class.class_eval do
20
+ # Make sure the objects will be ActiveModels
21
+ include ::ActiveModel::Model unless include?(::ActiveModel::Model)
22
+
23
+ # Make sure that it has dirty tracking
24
+ include ::ActiveModel::Dirty unless include?(::ActiveModel::Dirty)
25
+
26
+ # Has this model changed? Override's <code>ActiveModel::Dirty</code>'s base behavior to properly handle
27
+ # recursive changes.
28
+ #
29
+ # @return [Boolean] true if any attribute has changed, false otherwise
30
+ def changed?
31
+ # Note: this method is implemented here versus in the module overall because if it is implemented in the
32
+ # module overall, it doesn't properly override the implementation for <code>ActiveModel::Dirty</code> that
33
+ # gets dynamically pulled in using the <code>included</code> hook.
34
+ super || self.class.ancestry_active_json_model_attributes.any? do |attr|
35
+ val = send(attr.name)
36
+ val&.respond_to?(:changed?) && val.changed?
37
+ end
38
+ end
39
+
40
+ # For new/loaded tracking
41
+ @_active_json_model_dumped = false
42
+ @_active_json_model_loaded = false
43
+
44
+ # Register model validation to handle recursive validation into the model tree
45
+ validate :active_json_model_validate
46
+
47
+ def initialize(**kwargs)
48
+ # Apply default values values that weren't specified
49
+ self.class.active_json_model_attributes.filter{|attr| !attr.default.nil?}.each do |attr|
50
+ unless kwargs.key?(attr.name)
51
+ # Set as an instance variable to avoid being recorded as a true set value
52
+ instance_variable_set("@#{attr.name}", attr.get_default_value)
53
+
54
+ # Record that the value is a default
55
+ instance_variable_set("@#{attr.name}_is_default", true)
56
+ end
57
+ end
58
+
59
+ # You cannot set the fixed JSON attributes by a setter method. Instead, initialize the member variable
60
+ # directly
61
+ self.class.active_json_model_fixed_attributes.each do |k, v|
62
+ instance_variable_set("@#{k}", v)
63
+ end
64
+
65
+ # Invoke the superclass constructor to let active model do the work of setting the attributes
66
+ super(**kwargs).tap do |_|
67
+ # Clear out any recorded changes as this object is starting fresh
68
+ clear_changes_information
69
+ end
70
+ end
71
+ end
72
+ end
73
+
74
+ # Was this instance loaded from JSON?
75
+ # @return [Boolean] true if loaded from JSON, false otherwise
76
+ def loaded?
77
+ @_active_json_model_loaded
78
+ end
79
+
80
+ # Was this instance dumped to JSON?
81
+ # @return [Boolean] true if dumped to JSON, false otherwise
82
+ def dumped?
83
+ @_active_json_model_dumped
84
+ end
85
+
86
+ # Is this a new instance that was created without loading, and has yet to be dumped?
87
+ # @return [Boolean] true if new, false otherwise
88
+ def new?
89
+ !loaded? && !dumped?
90
+ end
91
+
92
+ # Load data for this instance from a JSON hash
93
+ #
94
+ # @param json_hash [Hash] hash of data to be loaded into a model instance
95
+ def load_from_json(json_hash)
96
+ # Record this object was loaded
97
+ @_active_json_model_loaded = true
98
+
99
+ # Cache fixed attributes
100
+ fixed_attributes = self.class.ancestry_active_json_model_fixed_attributes
101
+
102
+ # Iterate over all the allowed attributes
103
+ self.class.ancestry_active_json_model_attributes.each do |attr|
104
+ begin
105
+ # The value that was set from the hash
106
+ json_value = json_hash[attr.name]
107
+ rescue TypeError
108
+ raise ArgumentError.new("Invalid value specified for json_hash. Expected hash-like object received #{json_hash.class}")
109
+ end
110
+
111
+
112
+ # Now translate the raw value into how it should interpreted
113
+ if fixed_attributes.key?(attr.name)
114
+ # Doesn't matter what the value was. Must confirm to the fixed value.
115
+ value = fixed_attributes[attr.name]
116
+ elsif !json_hash.key?(attr.name) && attr.default
117
+ # Note that this logic reflects that an explicit nil value is not the same as not set. Only not set
118
+ # generates the default.
119
+ value = attr.get_default_value
120
+ elsif attr.load_proc && json_value
121
+ # Invoke the proc to get a value back. This gives the proc the opportunity to either generate a value
122
+ # concretely or return a class to use.
123
+ value = if attr.load_proc.arity == 2
124
+ attr.load_proc.call(json_value, json_hash)
125
+ else
126
+ attr.load_proc.call(json_value)
127
+ end
128
+
129
+ if value
130
+ # If it's a class, new it up assuming it will support loading from JSON.
131
+ if value.is_a?(Class)
132
+ # First check if it supports polymorphic behavior.
133
+ if value.respond_to?(:active_json_model_concrete_class_from_ancestry_polymorphic)
134
+ value = value.active_json_model_concrete_class_from_ancestry_polymorphic(json_value) || value
135
+ end
136
+
137
+ # New up an instance of the class for loading
138
+ value = value.new
139
+ end
140
+
141
+ # If supported, recursively allow the model to load from JSON
142
+ if value.respond_to?(:load_from_json)
143
+ value.load_from_json(json_value)
144
+ end
145
+ end
146
+ elsif attr.clazz && json_value
147
+ # Special case certain builtin types
148
+ if Integer == attr.clazz
149
+ value = json_value.to_i
150
+ elsif Float == attr.clazz
151
+ value = json_value.to_f
152
+ elsif String == attr.clazz
153
+ value = json_value.to_s
154
+ elsif Symbol == attr.clazz
155
+ value = json_value.to_sym
156
+ elsif DateTime == attr.clazz
157
+ value = DateTime.iso8601(json_value)
158
+ elsif Date == attr.clazz
159
+ value = Date.iso8601(json_value)
160
+ else
161
+ # First check if it supports polymorphic behavior.
162
+ clazz = if attr.clazz.respond_to?(:active_json_model_concrete_class_from_ancestry_polymorphic)
163
+ value = attr.clazz.active_json_model_concrete_class_from_ancestry_polymorphic(json_value) || attr.clazz
164
+ else
165
+ attr.clazz
166
+ end
167
+
168
+ # New up the instance
169
+ value = clazz.new
170
+
171
+ # If supported, recursively allow the model to load from JSON
172
+ if value.respond_to?(:load_from_json)
173
+ value.load_from_json(json_value)
174
+ end
175
+ end
176
+ else
177
+ value = json_value
178
+ end
179
+
180
+ # Actually set the value on the instance
181
+ send("#{attr.name}=", value)
182
+ end
183
+
184
+ # Now that the load is complete, mark dirty tracking as clean
185
+ clear_changes_information
186
+
187
+ # Invoke any on-load callbacks
188
+ self.class.ancestry_active_json_model_load_callbacks.each do |cb|
189
+ cb.invoke(self)
190
+ end
191
+ end
192
+
193
+ def dump_to_json
194
+ # Record that the data has been dumped
195
+ @_active_json_model_dumped = true
196
+
197
+ # Get the attributes that are constants in the JSON rendering
198
+ fixed_attributes = self.class.ancestry_active_json_model_fixed_attributes
199
+
200
+ key_values = []
201
+
202
+ self.class.ancestry_active_json_model_attributes.each do |attr|
203
+ # Skip on the off chance of a name collision between normal and fixed attributes
204
+ next if fixed_attributes.key?(attr.name)
205
+
206
+ # Don't render the value if it is a default and configured not to
207
+ next unless attr.render_default || !send("#{attr.name}_is_default?")
208
+
209
+ # Get the value from the underlying attribute from the instance
210
+ value = send(attr.name)
211
+
212
+ # Recurse if the value is itself an ActiveJsonModel
213
+ if value&.respond_to?(:dump_to_json)
214
+ value = value.dump_to_json
215
+ end
216
+
217
+ if attr.dump_proc
218
+ # Invoke the proc to do the translation
219
+ value = if attr.dump_proc.arity == 2
220
+ attr.dump_proc.call(value, self)
221
+ else
222
+ attr.dump_proc.call(value)
223
+ end
224
+ end
225
+
226
+ key_values.push([attr.name, value])
227
+ end
228
+
229
+ # Iterate over all the allowed attributes (fixed and regular)
230
+ fixed_attributes.each do |key, value|
231
+ # Recurse if the value is itself an ActiveJsonModel (unlikely)
232
+ if value&.respond_to?(:dump_to_json)
233
+ value = value.dump_to_json
234
+ end
235
+
236
+ key_values.push([key, value])
237
+ end
238
+
239
+ # Render the array of key-value pairs to a hash
240
+ key_values.to_h.tap do |_|
241
+ # All changes are cleared after dump
242
+ clear_changes_information
243
+ end
244
+ end
245
+
246
+ # Validate method that handles recursive validation into <code>json_attribute</code>s. Individual validations
247
+ # on attributes for this model will be handled by the standard mechanism.
248
+ def active_json_model_validate
249
+ self.class.active_json_model_attributes.each do |attr|
250
+ val = send(attr.name)
251
+
252
+ # Check if attribute value is an ActiveJsonModel
253
+ if val && val.respond_to?(:valid?)
254
+ # This call to <code>valid?</code> is important because it will actually trigger recursive validations
255
+ unless val.valid?
256
+ val.errors.each do |error|
257
+ errors.add("#{attr.name}.#{error.attribute}".to_sym, error.message)
258
+ end
259
+ end
260
+ end
261
+ end
262
+ end
263
+
264
+ module ClassMethods
265
+ if defined?(::ActiveRecord)
266
+ # Allow this model to be used as ActiveRecord attribute type in Rails 5+.
267
+ #
268
+ # E.g.
269
+ # class Credentials < ::ActiveJsonModel; end;
270
+ #
271
+ # class Integration < ActiveRecord::Base
272
+ # attribute :credentials, Credentials.attribute_type
273
+ # end
274
+ #
275
+ # Note that this data would be stored as jsonb in the database
276
+ def attribute_type
277
+ @attribute_type ||= ActiveModelJsonSerializableType.new(self)
278
+ end
279
+
280
+ # Allow this model to be used as ActiveRecord attribute type in Rails 5+.
281
+ #
282
+ # E.g.
283
+ # class SecureCredentials < ::ActiveJsonModel; end;
284
+ #
285
+ # class Integration < ActiveRecord::Base
286
+ # attribute :secure_credentials, SecureCredentials.encrypted_attribute_type
287
+ # end
288
+ #
289
+ # Note that this data would be stored as a string in the database, encrypted using
290
+ # a symmetric key at the application level.
291
+ def encrypted_attribute_type
292
+ @encrypted_attribute_type ||= ActiveModelJsonSerializableEncryptedType.new(self)
293
+ end
294
+ end
295
+
296
+ # Attributes that have been defined for this class using <code>json_attribute</code>.
297
+ #
298
+ # @return [Array<JsonAttribute>] Json attributes for this class
299
+ def active_json_model_attributes
300
+ @__active_json_model_attributes ||= []
301
+ end
302
+
303
+ # A list of procs that will be executed after data has been loaded.
304
+ #
305
+ # @return [Array<Proc>] array of procs executed after data is loaded
306
+ def active_json_model_load_callbacks
307
+ @__active_json_model_load_callbacks ||= []
308
+ end
309
+
310
+ # A factory defined via <code>json_polymorphic_via</code> that allows the class to choose different concrete
311
+ # classes based on the data in the JSON. Property is for only this class, not the entire class hierarchy.
312
+ #
313
+ # @ return [Proc, nil] proc used to select the concrete base class for the model class
314
+ def active_json_model_polymorphic_factory
315
+ @__active_json_model_polymorphic_factory
316
+ end
317
+
318
+ # Filter the ancestor hierarchy to those built with <code>ActiveJsonModel::Model</code> concerns
319
+ #
320
+ # @return [Array<Class>] reversed array of classes in the hierarchy of this class that include ActiveJsonModel
321
+ def active_json_model_ancestors
322
+ self.ancestors.filter{|o| o.respond_to?(:active_json_model_attributes)}.reverse
323
+ end
324
+
325
+ # Get all active json model attributes for all the class hierarchy tree
326
+ #
327
+ # @return [Array<JsonAttribute>] Json attributes for the ancestry tree
328
+ def ancestry_active_json_model_attributes
329
+ self.active_json_model_ancestors.flat_map(&:active_json_model_attributes)
330
+ end
331
+
332
+ # Get all active json model after load callbacks for all the class hierarchy tree
333
+ #
334
+ # @return [Array<AfterLoadCallback>] After load callbacks for the ancestry tree
335
+ def ancestry_active_json_model_load_callbacks
336
+ self.active_json_model_ancestors.flat_map(&:active_json_model_load_callbacks)
337
+ end
338
+
339
+ # Get all polymorphic factories in the ancestry chain.
340
+ #
341
+ # @return [Array<Proc>] After load callbacks for the ancestry tree
342
+ def ancestry_active_json_model_polymorphic_factory
343
+ self.active_json_model_ancestors.map(&:active_json_model_polymorphic_factory).filter(&:present?)
344
+ end
345
+
346
+ # Get the hash of key-value pairs that are fixed for this class. Fixed attributes render to the JSON payload
347
+ # but cannot be set directly.
348
+ #
349
+ # @return [Hash] set of fixed attributes for this class
350
+ def active_json_model_fixed_attributes
351
+ @__active_json_fixed_attributes ||= {}
352
+ end
353
+
354
+ # Get the hash of key-value pairs that are fixed for this class hierarchy. Fixed attributes render to the JSON
355
+ # payload but cannot be set directly.
356
+ #
357
+ # @return [Hash] set of fixed attributes for this class hierarchy
358
+ def ancestry_active_json_model_fixed_attributes
359
+ self
360
+ .active_json_model_ancestors
361
+ .map{|a| a.active_json_model_fixed_attributes}
362
+ .reduce({}, :merge)
363
+ end
364
+
365
+ # Set a fixed attribute for the current class. A fixed attribute is a constant value that is set at the class
366
+ # level that still renders to the underlying JSON structure. This is useful when you have a hierarchy of classes
367
+ # which may have certain properties set that differentiate them in the rendered json. E.g. a <code>type</code>
368
+ # attribute.
369
+ #
370
+ # Example:
371
+ #
372
+ # class BaseWorkflow
373
+ # include ::ActiveJsonModel::Model
374
+ # json_attribute :name
375
+ # end
376
+ #
377
+ # class EmailWorkflow < BaseWorkflow
378
+ # include ::ActiveJsonModel::Model
379
+ # json_fixed_attribute :type, 'email'
380
+ # end
381
+ #
382
+ # class WebhookWorkflow < BaseWorkflow
383
+ # include ::ActiveJsonModel::Model
384
+ # json_fixed_attribute :type, 'webhook'
385
+ # end
386
+ #
387
+ # workflows = [EmailWorkflow.new(name: 'wf1'), WebhookWorkflow.new(name: 'wf2')].map(&:dump_to_json)
388
+ # # [{"name": "wf1", "type": "email"}, {"name": "wf2", "type": "webhook"}]
389
+ #
390
+ # @param name [Symbol] the name of the attribute
391
+ # @param value [Object] the value to set the attribute to
392
+ def json_fixed_attribute(name, value:)
393
+ active_json_model_fixed_attributes[name.to_sym] = value
394
+
395
+ # We could handle fixed attributes as just a get method, but this approach keeps them consistent with the
396
+ # other attributes for things like changed tracking.
397
+ instance_variable_set("@#{name}", value)
398
+
399
+ # Define ActiveModel attribute methods (https://api.rubyonrails.org/classes/ActiveModel/AttributeMethods.html)
400
+ # for this class. E.g. reset_<name>
401
+ #
402
+ # Used for dirty tracking for the model.
403
+ #
404
+ # @see https://api.rubyonrails.org/classes/ActiveModel/AttributeMethods/ClassMethods.html#method-i-define_attribute_methods
405
+ define_attribute_methods name
406
+
407
+ # Define the getter for this attribute
408
+ attr_reader name
409
+
410
+ # Define the setter method to prevent the value from being changed.
411
+ define_method "#{name}=" do |v|
412
+ unless value == v
413
+ raise RuntimeError.new("#{self.class}.#{name} is an Active JSON Model fixed attribute with a value of '#{value}'. It's value cannot be set to '#{v}''.")
414
+ end
415
+ end
416
+ end
417
+
418
+ # Define a new attribute for the model that will be backed by a JSON attribute
419
+ #
420
+ # @param name [Symbol, String] the name of the attribute
421
+ # @param clazz [Class] the Class to use to initialize the object type
422
+ # @param default [Object] the default value for the attribute
423
+ # @param render_default [Boolean] should the default value be rendered to JSON? Default is true. Note this only
424
+ # applies if the value has not be explicitly set. If explicitly set, the value renders, regardless of if
425
+ # the value is the same as the default value.
426
+ # @param validation [Object] object whose properties correspond to settings for active model validators
427
+ # @param serialize_with [Proc] proc to generate a value from the value to be rendered to JSON. Given
428
+ # <code>value</code> and <code>parent_model</code> (optional parameter) values. The value returned is
429
+ # assumed to be a valid JSON value.
430
+ # @param deserialize_with [Proc] proc to deserialize a value from JSON. This is an alternative to passing a block
431
+ # (<code>load_proc</code>) to the method and has the same semantics.
432
+ # @param load_proc [Proc] proc that allows the model to customize the value generated. The proc is passed
433
+ # <code>value_json</code> and <code>parent_json</code>. <code>value_json</code> is the value for this
434
+ # sub-property, and <code>parent_json</code> (optional parameter) is the json for the parent object. This
435
+ # proc can either return a class or a concrete instance. If a class is returned, a new instance of the
436
+ # class will be created on JSON load, and if supported, the sub-JSON will be loaded into it. If a concrete
437
+ # value is returned, it is assumed this is the reconstructed value. This proc allows for simplified
438
+ # polymorphic load behavior as well as custom deserialization.
439
+ def json_attribute(name, clazz = nil, default: nil, render_default: true, validation: nil,
440
+ serialize_with: nil, deserialize_with: nil, &load_proc)
441
+ if deserialize_with && load_proc
442
+ raise ArgumentError.new("Cannot specify both deserialize_with and block to json_attribute")
443
+ end
444
+
445
+ name = name.to_sym
446
+
447
+ # Add the attribute to the collection of json attributes defined for this class
448
+ active_json_model_attributes.push(
449
+ JsonAttribute.new(
450
+ name: name,
451
+ clazz: clazz,
452
+ default: default,
453
+ render_default: render_default,
454
+ validation: validation,
455
+ dump_proc: serialize_with,
456
+ load_proc: load_proc || deserialize_with
457
+ )
458
+ )
459
+
460
+ # Define ActiveModel attribute methods (https://api.rubyonrails.org/classes/ActiveModel/AttributeMethods.html)
461
+ # for this class. E.g. reset_<name>
462
+ #
463
+ # Used for dirty tracking for the model.
464
+ #
465
+ # @see https://api.rubyonrails.org/classes/ActiveModel/AttributeMethods/ClassMethods.html#method-i-define_attribute_methods
466
+ define_attribute_methods name
467
+
468
+ # Define the getter for this attribute
469
+ attr_reader name
470
+
471
+ # Define the setter for this attribute with proper change tracking
472
+ #
473
+ # @param value [...] the value to set the attribute to
474
+ define_method "#{name}=" do |value|
475
+ # Trigger ActiveModle's change tracking system if the value is actually changing
476
+ # @see https://stackoverflow.com/questions/23958170/understanding-attribute-will-change-method
477
+ send("#{name}_will_change!") unless value == instance_variable_get("@#{name}")
478
+
479
+ # Set the value as a direct instance variable
480
+ instance_variable_set("@#{name}", value)
481
+
482
+ # Record that the value is not a default
483
+ instance_variable_set("@#{name}_is_default", false)
484
+ end
485
+
486
+ # Check if the attribute is set to the default value. This implies this value has never been set.
487
+ # @return [Boolean] true if the value has been explicitly set or loaded, false otherwise
488
+ define_method "#{name}_is_default?" do
489
+ !!instance_variable_get("@#{name}_is_default")
490
+ end
491
+
492
+ if validation
493
+ validates name, validation
494
+ end
495
+ end
496
+
497
+ # Define a polymorphic factory to choose the concrete class for the model.
498
+ #
499
+ # Example:
500
+ #
501
+ # class BaseWorkflow
502
+ # include ::ActiveJsonModel::Model
503
+ #
504
+ # json_polymorphic_via do |data|
505
+ # if data[:type] == 'email'
506
+ # EmailWorkflow
507
+ # else
508
+ # WebhookWorkflow
509
+ # end
510
+ # end
511
+ # end
512
+ #
513
+ # class EmailWorkflow < BaseWorkflow
514
+ # include ::ActiveJsonModel::Model
515
+ # json_fixed_attribute :type, 'email'
516
+ # end
517
+ #
518
+ # class WebhookWorkflow < BaseWorkflow
519
+ # include ::ActiveJsonModel::Model
520
+ # json_fixed_attribute :type, 'webhook'
521
+ # end
522
+ def json_polymorphic_via(&block)
523
+ @__active_json_model_polymorphic_factory = block
524
+ end
525
+
526
+ # Computes the concrete class that should be used to load the data based on the ancestry tree's
527
+ # <code>json_polymorphic_via</code>. Also handles potential recursion at the leaf nodes of the tree.
528
+ #
529
+ # @param data [Hash] the data being loaded from JSON
530
+ # @return [Class] the class to be used to load the JSON
531
+ def active_json_model_concrete_class_from_ancestry_polymorphic(data)
532
+ clazz = nil
533
+ ancestry_active_json_model_polymorphic_factory.each do |proc|
534
+ clazz = proc.call(data)
535
+ break if clazz
536
+ end
537
+
538
+ if clazz
539
+ if clazz != self && clazz.respond_to?(:active_json_model_concrete_class_from_ancestry_polymorphic)
540
+ clazz.active_json_model_concrete_class_from_ancestry_polymorphic(data) || clazz
541
+ else
542
+ clazz
543
+ end
544
+ else
545
+ self
546
+ end
547
+ end
548
+
549
+ # Register a new after load callback which is invoked after the instance is loaded from JSON
550
+ #
551
+ # @param method_name [Symbol, String] the name of the method to be invoked
552
+ # @param block [Proc] block to be executed after load. Will optionally be passed an instance of the loaded object.
553
+ def json_after_load(method_name=nil, &block)
554
+ raise ArgumentError.new("Must specify method or block for ActiveJsonModel after load") unless method_name || block
555
+ raise ArgumentError.new("Can only specify method or block for ActiveJsonModel after load") if method_name && block
556
+
557
+ active_json_model_load_callbacks.push(
558
+ AfterLoadCallback.new(
559
+ method_name: method_name,
560
+ block: block
561
+ )
562
+ )
563
+ end
564
+
565
+ # Load an instance of the class from JSON
566
+ #
567
+ # @param json_data [String, Hash] the data to be loaded into the instance. May be a hash or a string.
568
+ # @return Instance of the class
569
+ def load(json_data)
570
+ if json_data.nil? || (json_data.is_a?(String) && json_data.blank?)
571
+ return nil
572
+ end
573
+
574
+ # Get the data to a hash, regardless of the starting data type
575
+ data = json_data.is_a?(String) ? JSON.parse(json_data) : json_data
576
+
577
+ # Recursively make the value have indifferent access
578
+ data = ::ActiveJsonModel::Utils.recursively_make_indifferent(data)
579
+
580
+ # Get the concrete class from the ancestry tree's potential polymorphic behavior. Note this needs to be done
581
+ # for each sub property as well. This just covers the outermost case.
582
+ clazz = active_json_model_concrete_class_from_ancestry_polymorphic(data)
583
+ clazz.new.tap do |instance|
584
+ instance.load_from_json(data)
585
+ end
586
+ end
587
+
588
+ # Dump the specified object to JSON
589
+ #
590
+ # @param obj [self] object to dump to json
591
+ def dump(obj)
592
+ raise ArgumentError.new("Expected #{self} got #{obj.class} to dump to JSON") unless obj.is_a?(self)
593
+ obj.dump_to_json
594
+ end
595
+ end
596
+ end
597
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support'
4
+
5
+ module ActiveJsonModel
6
+ module Utils
7
+ def self.recursively_make_indifferent(val)
8
+ return val unless val&.is_a?(Hash) || val&.respond_to?(:map)
9
+
10
+ if val.is_a?(Hash)
11
+ val.with_indifferent_access.tap do |w|
12
+ w.each do |k, v|
13
+ w[k] = recursively_make_indifferent(v)
14
+ end
15
+ end
16
+ else
17
+ val.map do |v|
18
+ recursively_make_indifferent(v)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveJsonModel
4
+ VERSION = "0.1.0".freeze
5
+ end