attr_json 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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