attr_json 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.
data/lib/attr_json.rb ADDED
@@ -0,0 +1,18 @@
1
+ require "attr_json/version"
2
+
3
+ require "active_record"
4
+ require "active_record/connection_adapters/postgresql_adapter"
5
+
6
+ require 'attr_json/record'
7
+ require 'attr_json/model'
8
+ require 'attr_json/nested_attributes'
9
+ require 'attr_json/record/query_scopes'
10
+
11
+ # Dirty not supported on Rails 5.0
12
+ if Gem.loaded_specs["activerecord"].version.release >= Gem::Version.new('5.1')
13
+ require 'attr_json/record/dirty'
14
+ end
15
+
16
+ module AttrJson
17
+
18
+ end
@@ -0,0 +1,93 @@
1
+ require 'attr_json/type/array'
2
+
3
+ module AttrJson
4
+
5
+ # Represents a `attr_json` definition, on either a AttrJson::Record
6
+ # or AttrJson::Model. Normally this class is only used by
7
+ # AttrJson::AttributeDefinition::{Registry}.
8
+ class AttributeDefinition
9
+ NO_DEFAULT_PROVIDED = Object.new.freeze
10
+ VALID_OPTIONS = %i{container_attribute store_key default array}.freeze
11
+
12
+ attr_reader :name, :type, :original_args, :container_attribute
13
+
14
+ # @param name [Symbol,String]
15
+ # @param type [Symbol,ActiveModel::Type::Value]
16
+ #
17
+ # @option options store_key [Symbol,String]
18
+ # @option options container_attribute [Symbol,ActiveModel::Type::Value]
19
+ # Only means something in a AttrJson::Record, no meaning in a AttrJson::Model.
20
+ # @option options default [Object,Symbol,Proc] (nil)
21
+ # @option options array [Boolean] (false)
22
+ def initialize(name, type, options = {})
23
+ options.assert_valid_keys *VALID_OPTIONS
24
+ # saving original args for reflection useful for debugging, maybe other things.
25
+ @original_args = [name, type, options]
26
+
27
+ @name = name.to_sym
28
+
29
+ @container_attribute = options[:container_attribute] && options[:container_attribute].to_s
30
+
31
+ @store_key = options[:store_key] && options[:store_key].to_s
32
+
33
+ @default = if options.has_key?(:default)
34
+ options[:default]
35
+ else
36
+ NO_DEFAULT_PROVIDED
37
+ end
38
+
39
+ if type.is_a? Symbol
40
+ # ActiveModel::Type.lookup may make more sense, but ActiveModel::Type::Date
41
+ # seems to have a bug with multi-param assignment. Mostly they return
42
+ # the same types, but ActiveRecord::Type::Date works with multi-param assignment.
43
+ type = ActiveRecord::Type.lookup(type)
44
+ elsif ! type.is_a? ActiveModel::Type::Value
45
+ raise ArgumentError, "Second argument (#{type}) must be a symbol or instance of an ActiveModel::Type::Value subclass"
46
+ end
47
+ @type = (options[:array] == true ? AttrJson::Type::Array.new(type) : type)
48
+ end
49
+
50
+ def cast(value)
51
+ type.cast(value)
52
+ end
53
+
54
+ def serialize(value)
55
+ type.serialize(value)
56
+ end
57
+
58
+ def deserialize(value)
59
+ type.deserialize(value)
60
+ end
61
+
62
+ def has_custom_store_key?
63
+ !!@store_key
64
+ end
65
+
66
+ def store_key
67
+ (@store_key || name).to_s
68
+ end
69
+
70
+ def has_default?
71
+ @default != NO_DEFAULT_PROVIDED
72
+ end
73
+
74
+ def provide_default!
75
+ unless has_default?
76
+ raise ArgumentError.new("This #{self.class.name} does not have a default defined!")
77
+ end
78
+
79
+ # Seems weird to assume a Proc can't be the default itself, but I guess
80
+ # Proc's aren't serializable, so fine assumption. Modeled after:
81
+ # https://github.com/rails/rails/blob/f2dfd5c6fdffdf65e6f07aae8e855ac802f9302f/activerecord/lib/active_record/attribute/user_provided_default.rb#L12-L16
82
+ if @default.is_a?(Proc)
83
+ cast(@default.call)
84
+ else
85
+ cast(@default)
86
+ end
87
+ end
88
+
89
+ def array_type?
90
+ type.is_a? AttrJson::Type::Array
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,93 @@
1
+ require 'attr_json/attribute_definition'
2
+
3
+ module AttrJson
4
+ class AttributeDefinition
5
+ # Attached to a class to record the json attributes registered,
6
+ # with either AttrJson::Record or AttrJson::Model.
7
+ #
8
+ # Think of it as mostly like a hash keyed by attribute name, value
9
+ # an AttributeDefinition.
10
+ #
11
+ # It is expected to be used by AttrJson::Record and AttrJson::Model,
12
+ # you shouldn't need to interact with it directly.
13
+ #
14
+ # It is intentionally immutable to make it harder to accidentally mutate
15
+ # a registry shared with superclass in a `class_attribute`, instead of
16
+ # properly assigning a new modified registry.
17
+ #
18
+ # self.some_registry_attribute = self.some_registry_attribute.with(
19
+ # attr_definition_1, attr_definition_2
20
+ # )
21
+ # # => Returns a NEW AttributeDefinition object
22
+ #
23
+ # All references in code to "definition" are to a AttrJson::AttributeDefinition instance.
24
+ class Registry
25
+ def initialize(hash = {})
26
+ @name_to_definition = hash
27
+ @store_key_to_definition = {}
28
+ definitions.each { |d| store_key_index!(d) }
29
+ end
30
+
31
+ def fetch(key, *args, &block)
32
+ @name_to_definition.fetch(key.to_sym, *args, &block)
33
+ end
34
+
35
+ def [](key)
36
+ @name_to_definition[key.to_sym]
37
+ end
38
+
39
+ def has_attribute?(key)
40
+ @name_to_definition.has_key?(key.to_sym)
41
+ end
42
+
43
+ def type_for_attribute(key)
44
+ self[key].type
45
+ end
46
+
47
+ # Can return nil if none found.
48
+ def store_key_lookup(container_attribute, store_key)
49
+ @store_key_to_definition[container_attribute.to_s] &&
50
+ @store_key_to_definition[container_attribute.to_s][store_key.to_s]
51
+ end
52
+
53
+ def definitions
54
+ @name_to_definition.values
55
+ end
56
+
57
+ def container_attributes
58
+ @store_key_to_definition.keys.collect(&:to_s)
59
+ end
60
+
61
+ # This is how you register additional definitions, as a non-mutating
62
+ # return-a-copy operation.
63
+ def with(*definitions)
64
+ self.class.new(@name_to_definition).tap do |copied|
65
+ definitions.each do |defin|
66
+ copied.add!(defin)
67
+ end
68
+ end
69
+ end
70
+
71
+ protected
72
+
73
+ def add!(definition)
74
+ if @name_to_definition.has_key?(definition.name)
75
+ raise ArgumentError, "Can't add, conflict with existing attribute name `#{definition.name.to_sym}`: #{@name_to_definition[definition.name].original_args}"
76
+ end
77
+ @name_to_definition[definition.name.to_sym] = definition
78
+ store_key_index!(definition)
79
+ end
80
+
81
+ def store_key_index!(definition)
82
+ container_hash = (@store_key_to_definition[definition.container_attribute.to_s] ||= {})
83
+
84
+ if container_hash.has_key?(definition.store_key.to_s)
85
+ existing = container_hash[definition.store_key.to_s]
86
+ raise ArgumentError, "Can't add, store key `#{definition.store_key}` conflicts with existing attribute: #{existing.original_args}"
87
+ end
88
+
89
+ container_hash[definition.store_key.to_s] = definition
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,270 @@
1
+ require 'active_support/concern'
2
+ require 'active_model/type'
3
+
4
+ require 'attr_json/attribute_definition'
5
+ require 'attr_json/attribute_definition/registry'
6
+
7
+ require 'attr_json/type/model'
8
+ require 'attr_json/model/cocoon_compat'
9
+
10
+ module AttrJson
11
+
12
+ # Meant for use in a plain class, turns it into an ActiveModel::Model
13
+ # with attr_json support. NOT for use in an ActiveRecord::Base model,
14
+ # see `Record` for ActiveRecord use.
15
+ #
16
+ # Creates an ActiveModel object with _typed_ attributes, easily serializable
17
+ # to json, and with a corresponding ActiveModel::Type representing the class.
18
+ # Meant for use as an attribute of a AttrJson::Record. Can be nested,
19
+ # AttrJson::Models can have attributes that are other AttrJson::Models.
20
+ #
21
+ # @note Includes ActiveModel::Model whether you like it or not. TODO, should it?
22
+ #
23
+ # You can control what happens if you set an unknown key (one that you didn't
24
+ # register with `attr_json`) with the class attribute `attr_json_unknown_key`.
25
+ # * :raise (default) raise ActiveModel::UnknownAttributeError
26
+ # * :strip Ignore the unknown key and do not include it, without raising.
27
+ # * :allow Allow the unknown key and it's value to be in the serialized hash,
28
+ # and written to the database. May be useful for legacy data or columns
29
+ # that other software touches, to let unknown keys just flow through.
30
+ module Model
31
+ extend ActiveSupport::Concern
32
+
33
+ include ActiveModel::Model
34
+ include ActiveModel::Serialization
35
+ #include ActiveModel::Dirty
36
+
37
+ included do
38
+ if self < ActiveRecord::Base
39
+ raise TypeError, "AttrJson::Model is not for an ActiveRecord::Base model. #{self} appears to be one. Are you looking for ::AttrJson::Record?"
40
+ end
41
+
42
+ class_attribute :attr_json_registry, instance_accessor: false
43
+ self.attr_json_registry = ::AttrJson::AttributeDefinition::Registry.new
44
+
45
+ # :raise, :strip, :allow. :raise is default. Is there some way to enforce this.
46
+ class_attribute :attr_json_unknown_key
47
+ self.attr_json_unknown_key ||= :raise
48
+ end
49
+
50
+ class_methods do
51
+ # Like `.new`, but translate store keys in hash
52
+ def new_from_serializable(attributes = {})
53
+ attributes = attributes.transform_keys do |key|
54
+ # store keys in arguments get translated to attribute names on initialize.
55
+ if attribute_def = self.attr_json_registry.store_key_lookup("", key.to_s)
56
+ attribute_def.name.to_s
57
+ else
58
+ key
59
+ end
60
+ end
61
+ self.new(attributes)
62
+ end
63
+
64
+ def to_type
65
+ @type ||= AttrJson::Type::Model.new(self)
66
+ end
67
+
68
+ # Type can be an instance of an ActiveModel::Type::Value subclass, or a symbol that will
69
+ # be looked up in `ActiveModel::Type.lookup`
70
+ #
71
+ # @param name [Symbol,String] name of attribute
72
+ #
73
+ # @param type [ActiveModel::Type::Value] An instance of an ActiveModel::Type::Value (or subclass)
74
+ #
75
+ # @option options [Boolean] :array (false) Make this attribute an array of given type.
76
+ #
77
+ # @option options [Object] :default (nil) Default value, if a Proc object it will be #call'd
78
+ # for default.
79
+ #
80
+ # @option options [String,Symbol] :store_key (nil) Serialize to JSON using
81
+ # given store_key, rather than name as would be usual.
82
+ #
83
+ # @option options [Boolean] :validate (true) Create an ActiveRecord::Validations::AssociatedValidator so
84
+ # validation errors on the attributes post up to self.
85
+ def attr_json(name, type, **options)
86
+ options.assert_valid_keys(*(AttributeDefinition::VALID_OPTIONS - [:container_attribute] + [:validate]))
87
+
88
+ self.attr_json_registry = attr_json_registry.with(
89
+ AttributeDefinition.new(name.to_sym, type, options.except(:validate))
90
+ )
91
+
92
+ # By default, automatically validate nested models
93
+ if type.kind_of?(AttrJson::Type::Model) && options[:validate] != false
94
+ # Yes. we're passing an ActiveRecord::Validations validator, but
95
+ # it works fine for ActiveModel. If this changes in the future, tests will catch.
96
+ self.validates_with ActiveRecord::Validations::AssociatedValidator, attributes: [name.to_sym]
97
+ end
98
+
99
+ _attr_jsons_module.module_eval do
100
+ define_method("#{name}=") do |value|
101
+ _attr_json_write(name.to_s, value)
102
+ end
103
+
104
+ define_method("#{name}") do
105
+ attributes[name.to_s]
106
+ end
107
+ end
108
+ end
109
+
110
+ # This should kind of be considered 'protected', but the semantics
111
+ # of how we want to call it don't give us a visibility modifier that works.
112
+ # Prob means refactoring called for. TODO?
113
+ def fill_in_defaults(hash)
114
+ # Only if we need to mutate it to add defaults, we'll dup it first. deep_dup not neccesary
115
+ # since we're only modifying top-level here.
116
+ duped = false
117
+ attr_json_registry.definitions.each do |definition|
118
+ if definition.has_default? && ! (hash.has_key?(definition.store_key.to_s) || hash.has_key?(definition.store_key.to_sym))
119
+ unless duped
120
+ hash = hash.dup
121
+ duped = true
122
+ end
123
+
124
+ hash[definition.store_key] = definition.provide_default!
125
+ end
126
+ end
127
+
128
+ hash
129
+ end
130
+
131
+ private
132
+
133
+ # Define an anonymous module and include it, so can still be easily
134
+ # overridden by concrete class. Design cribbed from ActiveRecord::Store
135
+ # https://github.com/rails/rails/blob/4590d7729e241cb7f66e018a2a9759cb3baa36e5/activerecord/lib/active_record/store.rb
136
+ def _attr_jsons_module # :nodoc:
137
+ @_attr_jsons_module ||= begin
138
+ mod = Module.new
139
+ include mod
140
+ mod
141
+ end
142
+ end
143
+ end
144
+
145
+ def initialize(attributes = {})
146
+ if !attributes.respond_to?(:transform_keys)
147
+ raise ArgumentError, "When assigning attributes, you must pass a hash as an argument."
148
+ end
149
+
150
+ super(self.class.fill_in_defaults(attributes))
151
+ end
152
+
153
+ def attributes
154
+ @attributes ||= {}
155
+ end
156
+
157
+ # ActiveModel method, called in initialize. overridden.
158
+ # from https://github.com/rails/rails/blob/42a16a4d6514f28e05f1c22a5f9125d194d9c7cb/activemodel/lib/active_model/attribute_assignment.rb
159
+ def assign_attributes(new_attributes)
160
+ if !new_attributes.respond_to?(:stringify_keys)
161
+ raise ArgumentError, "When assigning attributes, you must pass a hash as an argument."
162
+ end
163
+ return if new_attributes.empty?
164
+
165
+ # stringify keys just like https://github.com/rails/rails/blob/4f99a2186479d5f77460622f2c0f37708b3ec1bc/activemodel/lib/active_model/attribute_assignment.rb#L34
166
+ new_attributes.stringify_keys.each do |k, v|
167
+ setter = :"#{k}="
168
+ if respond_to?(setter)
169
+ public_send(setter, v)
170
+ else
171
+ _attr_json_write_unknown_attribute(k, v)
172
+ end
173
+ end
174
+ end
175
+
176
+ # This attribute from ActiveRecord makes SimpleForm happy, and able to detect
177
+ # type.
178
+ def type_for_attribute(attr_name)
179
+ self.class.attr_json_registry.type_for_attribute(attr_name)
180
+ end
181
+
182
+ # This attribute from ActiveRecord make SimpleForm happy, and able to detect
183
+ # type.
184
+ def has_attribute?(str)
185
+ self.class.attr_json_registry.has_attribute?(str)
186
+ end
187
+
188
+ # Override from ActiveModel::Serialization to #serialize
189
+ # by type to make sure any values set directly on hash still
190
+ # get properly type-serialized.
191
+ def serializable_hash(*options)
192
+ super.collect do |key, value|
193
+ if attribute_def = self.class.attr_json_registry[key.to_sym]
194
+ key = attribute_def.store_key
195
+ if value.kind_of?(Time) || value.kind_of?(DateTime)
196
+ value = value.utc.change(usec: 0)
197
+ end
198
+
199
+ value = attribute_def.serialize(value)
200
+ end
201
+ # Do we need unknown key handling here? Apparently not?
202
+ [key, value]
203
+ end.to_h
204
+ end
205
+
206
+ # ActiveRecord JSON serialization will insist on calling
207
+ # this, instead of the specified type's #serialize, at least in some cases.
208
+ # So it's important we define it -- the default #as_json added by ActiveSupport
209
+ # will serialize all instance variables, which is not what we want.
210
+ def as_json(*options)
211
+ serializable_hash(*options)
212
+ end
213
+
214
+ # We deep_dup on #to_h, you want attributes unduped, ask for #attributes.
215
+ def to_h
216
+ attributes.deep_dup
217
+ end
218
+
219
+ # Two AttrJson::Model objects are equal if they are the same class
220
+ # or one is a subclass of the other, AND their #attributes are equal.
221
+ # TODO: Should we allow subclasses to be equal, or should they have to be the
222
+ # exact same class?
223
+ def ==(other_object)
224
+ (other_object.is_a?(self.class) || self.is_a?(other_object.class)) &&
225
+ other_object.attributes == self.attributes
226
+ end
227
+
228
+ # ActiveRecord objects [have a](https://github.com/rails/rails/blob/v5.1.5/activerecord/lib/active_record/nested_attributes.rb#L367-L374)
229
+ # `_destroy`, related to `marked_for_destruction?` functionality used with AR nested attributes.
230
+ # We don't mark for destruction, our nested attributes implementation just deletes immediately,
231
+ # but having this simple method always returning false makes things work more compatibly
232
+ # and smoothly with standard code for nested attributes deletion in form builders.
233
+ def _destroy
234
+ false
235
+ end
236
+
237
+ private
238
+
239
+ def _attr_json_write(key, value)
240
+ if attribute_def = self.class.attr_json_registry[key.to_sym]
241
+ attributes[key.to_s] = attribute_def.cast(value)
242
+ else
243
+ # TODO, strict mode, ignore, raise, allow.
244
+ attributes[key.to_s] = value
245
+ end
246
+ end
247
+
248
+
249
+ def _attr_json_write_unknown_attribute(key, value)
250
+ case attr_json_unknown_key
251
+ when :strip
252
+ # drop it, no-op
253
+ when :allow
254
+ # just put it in the hash and let standard JSON casting have it
255
+ _attr_json_write(key, value)
256
+ else
257
+ # default, :raise
258
+ raise ActiveModel::UnknownAttributeError.new(self, key)
259
+ end
260
+ end
261
+
262
+ # ActiveModel override.
263
+ # Don't take from instance variables, take from the attributes
264
+ # hash itself. Docs suggest we can override this for this very
265
+ # use case: https://github.com/rails/rails/blob/e1e3be7c02acb0facbf81a97bbfe6d1a6e9ca598/activemodel/lib/active_model/serialization.rb#L152-L168
266
+ def read_attribute_for_serialization(key)
267
+ attributes[key]
268
+ end
269
+ end
270
+ end