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.
@@ -0,0 +1,27 @@
1
+ module AttrJson
2
+ module Model
3
+ # Meant for mix-in in a AttrJson::Model class, defines some methods that
4
+ # [cocoon](https://github.com/nathanvda/cocoon) insists upon, even though the
5
+ # implementation doesn't really matter for getting cocoon to work with our Models
6
+ # as nested models in forms with cocoon -- the methods just need to be there.
7
+ module CocoonCompat
8
+ extend ActiveSupport::Concern
9
+
10
+ class_methods do
11
+ # cocoon wants this. PR to cocoon to not?
12
+ def reflect_on_association(*args)
13
+ nil
14
+ end
15
+ end
16
+
17
+ # cocoon insists on asking, we don't know the answer, we'll just say 'no'
18
+ # PR to cocoon to not insist on this?
19
+ def new_record?
20
+ nil
21
+ end
22
+ def marked_for_destruction?
23
+ nil
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,92 @@
1
+ require 'attr_json/nested_attributes/builder'
2
+ require 'attr_json/nested_attributes/writer'
3
+
4
+ module AttrJson
5
+ # The implementation is based on ActiveRecord::NestedAttributes, from
6
+ # https://github.com/rails/rails/blob/a45f234b028fd4dda5338e5073a3bf2b8bf2c6fd/activerecord/lib/active_record/nested_attributes.rb
7
+ #
8
+ # Re-used, and customized/overrode methods to match our implementation.
9
+ # Copied over some implementation so we can use in ActiveModel's that original
10
+ # isn't compatible with.
11
+ # The original is pretty well put together and has had very low churn history.
12
+ #
13
+ #
14
+ # Much of the AR implementation, copied, just works,
15
+ # if we define `'#{attribute_name}_attributes='` methods that work. That's mostly what
16
+ # we have to do here.
17
+ #
18
+ # Unlike AR, we try to put most of our implementation in seperate
19
+ # implementation helper instances, instead of adding a bazillion methods to the model itself.
20
+ module NestedAttributes
21
+ extend ActiveSupport::Concern
22
+
23
+ class_methods do
24
+ # Much like ActiveRecord `accepts_nested_attributes_for`, but used with embedded
25
+ # AttrJson::Model-type attributes (single or array). See doc page on Forms support.
26
+ #
27
+ # Note some AR options are _not_ supported.
28
+ #
29
+ # * _allow_destroy_, no such option. Effectively always true, doesn't make sense to try to gate this with our implementation.
30
+ # * _update_only_, no such option. Not really relevant to this architecture where you're embedded models have no independent existence.
31
+ #
32
+ # @overload attr_json_accepts_nested_attributes_for(define_build_method: true, reject_if: nil, limit: nil)
33
+ # @param define_build_method [Boolean] Default true, provide `build_attribute_name`
34
+ # method that works like you expect. [Cocoon](https://github.com/nathanvda/cocoon),
35
+ # for example, requires this. When true, you will get `model.build_#{attr_name}`
36
+ # methods. For array attributes, the attr_name will be singularized, as AR does.
37
+ # @param reject_if [Symbol,Proc] Allows you to specify a Proc or a Symbol pointing to a method
38
+ # that checks whether a record should be built for a certain attribute
39
+ # hash. Much like in AR accepts_nested_attributes_for.
40
+ # @param limit [Integer,Proc,Symbol] Allows you to specify the maximum number of associated records that
41
+ # can be processed with the nested attributes. Much like AR accepts_nested_attributes for.
42
+ def attr_json_accepts_nested_attributes_for(*attr_names)
43
+ options = { define_build_method: true }
44
+ options.update(attr_names.extract_options!)
45
+ options.assert_valid_keys(:reject_if, :limit, :define_build_method)
46
+ options[:reject_if] = ActiveRecord::NestedAttributes::ClassMethods::REJECT_ALL_BLANK_PROC if options[:reject_if] == :all_blank
47
+
48
+ unless respond_to?(:nested_attributes_options)
49
+ # Add it when we're in a AttrJson::Model. In an ActiveRecord::Base we'll just use the
50
+ # existing one, it'll be okay.
51
+ # https://github.com/rails/rails/blob/c14deceb9f36f82cd5ca3db214d85e1642eb0bfd/activerecord/lib/active_record/nested_attributes.rb#L16
52
+ class_attribute :nested_attributes_options, instance_writer: false
53
+ self.nested_attributes_options ||= {}
54
+ end
55
+
56
+ attr_names.each do |attr_name|
57
+ attr_def = attr_json_registry[attr_name]
58
+
59
+ unless attr_def
60
+ raise ArgumentError, "No attr_json found for name '#{attr_name}'. Has it been defined yet?"
61
+ end
62
+
63
+ # We're sharing AR class attr in an AR, or using our own in a Model.
64
+ nested_attributes_options = self.nested_attributes_options.dup
65
+ nested_attributes_options[attr_name.to_sym] = options
66
+ self.nested_attributes_options = nested_attributes_options
67
+
68
+ _attr_jsons_module.module_eval do
69
+ if method_defined?(:"#{attr_name}_attributes=")
70
+ remove_method(:"#{attr_name}_attributes=")
71
+ end
72
+ define_method "#{attr_name}_attributes=" do |attributes|
73
+ Writer.new(self, attr_name).assign_nested_attributes(attributes)
74
+ end
75
+ end
76
+
77
+ if options[:define_build_method]
78
+ _attr_jsons_module.module_eval do
79
+ build_method_name = "build_#{attr_name.to_s.singularize}"
80
+ if method_defined?(build_method_name)
81
+ remove_method(build_method_name)
82
+ end
83
+ define_method build_method_name do |params = {}|
84
+ Builder.new(self, attr_name).build(params)
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,24 @@
1
+ module AttrJson
2
+ module NestedAttributes
3
+ # Implementation of `build_` methods, called by the `build_` methods
4
+ # {NestedAttributes} adds.
5
+ class Builder
6
+ attr_reader :model, :attr_name, :attr_def
7
+
8
+ def initialize(model, attr_name)
9
+ @model, @attr_name = model, attr_name,
10
+ @attr_def = model.class.attr_json_registry[attr_name]
11
+ end
12
+
13
+ def build(params = {})
14
+ if attr_def.array_type?
15
+ model.send("#{attr_name}=", (model.send(attr_name) || []) + [params])
16
+ return model.send("#{attr_name}").last
17
+ else
18
+ model.send("#{attr_name}=", params)
19
+ return model.send("#{attr_name}")
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,86 @@
1
+ module AttrJson
2
+ module NestedAttributes
3
+ # Rails has a weird "multiparameter attribute" thing, that is used for simple_form's
4
+ # date/time html entry (datetime may be ALL it's ever been used for in Rails!),
5
+ # using weird parameters in the HTTP query params like "dateattribute(2i)".
6
+ # It is weird code, and I do NOT really understand the implementation, but it's also
7
+ # very low-churn, hasn't changed much in recent Rails history.
8
+ #
9
+ # In Rails at present it's only on ActiveRecord, we need it used on our AttrJson::Models
10
+ # too, so we copy and paste extract it here, from:
11
+ # https://github.com/rails/rails/blob/42a16a4d6514f28e05f1c22a5f9125d194d9c7cb/activerecord/lib/active_record/attribute_assignment.rb
12
+ #
13
+ # We only use it in the `#{attr_name}_attributes=` methods added by {NestedAttributes},
14
+ # that's enough to get what we need for support of this stuff in our stuff, for form submisisons
15
+ # using rails-style date/time inputs as used eg in simple_form. And then we don't
16
+ # need to polute anything outside of NestedAttributes module with this crazy stuff.
17
+ class MultiparameterAttributeWriter
18
+ attr_reader :model
19
+ def initialize(model)
20
+ @model = model
21
+ end
22
+
23
+ # Copied from Rails. https://github.com/rails/rails/blob/42a16a4d6514f28e05f1c22a5f9125d194d9c7cb/activerecord/lib/active_record/attribute_assignment.rb#L39
24
+ #
25
+ # Instantiates objects for all attribute classes that needs more than one constructor parameter. This is done
26
+ # by calling new on the column type or aggregation type (through composed_of) object with these parameters.
27
+ # So having the pairs written_on(1) = "2004", written_on(2) = "6", written_on(3) = "24", will instantiate
28
+ # written_on (a date type) with Date.new("2004", "6", "24"). You can also specify a typecast character in the
29
+ # parentheses to have the parameters typecasted before they're used in the constructor. Use i for Integer and
30
+ # f for Float. If all the values for a given attribute are empty, the attribute will be set to +nil+.
31
+ def assign_multiparameter_attributes(pairs)
32
+ execute_callstack_for_multiparameter_attributes(
33
+ extract_callstack_for_multiparameter_attributes(pairs)
34
+ )
35
+ end
36
+
37
+ protected
38
+
39
+ # copied from Rails https://github.com/rails/rails/blob/42a16a4d6514f28e05f1c22a5f9125d194d9c7cb/activerecord/lib/active_record/attribute_assignment.rb#L45
40
+ def execute_callstack_for_multiparameter_attributes(callstack)
41
+ errors = []
42
+ callstack.each do |name, values_with_empty_parameters|
43
+ begin
44
+ if values_with_empty_parameters.each_value.all?(&:nil?)
45
+ values = nil
46
+ else
47
+ values = values_with_empty_parameters
48
+ end
49
+ model.send("#{name}=", values)
50
+ rescue => ex
51
+ errors << ActiveRecord::AttributeAssignmentError.new("error on assignment #{values_with_empty_parameters.values.inspect} to #{name} (#{ex.message})", ex, name)
52
+ end
53
+ end
54
+ unless errors.empty?
55
+ error_descriptions = errors.map(&:message).join(",")
56
+ raise ActiveRecord::MultiparameterAssignmentErrors.new(errors), "#{errors.size} error(s) on assignment of multiparameter attributes [#{error_descriptions}]"
57
+ end
58
+ end
59
+
60
+ # copied from Rails https://github.com/rails/rails/blob/42a16a4d6514f28e05f1c22a5f9125d194d9c7cb/activerecord/lib/active_record/attribute_assignment.rb#L65
61
+ def extract_callstack_for_multiparameter_attributes(pairs)
62
+ attributes = {}
63
+
64
+ pairs.each do |(multiparameter_name, value)|
65
+ attribute_name = multiparameter_name.split("(").first
66
+ attributes[attribute_name] ||= {}
67
+
68
+ parameter_value = value.empty? ? nil : type_cast_attribute_value(multiparameter_name, value)
69
+ attributes[attribute_name][find_parameter_position(multiparameter_name)] ||= parameter_value
70
+ end
71
+
72
+ attributes
73
+ end
74
+
75
+ # copied from Rails https://github.com/rails/rails/blob/42a16a4d6514f28e05f1c22a5f9125d194d9c7cb/activerecord/lib/active_record/attribute_assignment.rb#L79
76
+ def type_cast_attribute_value(multiparameter_name, value)
77
+ multiparameter_name =~ /\([0-9]*([if])\)/ ? value.send("to_" + $1) : value
78
+ end
79
+
80
+ # copied from Rails https://github.com/rails/rails/blob/42a16a4d6514f28e05f1c22a5f9125d194d9c7cb/activerecord/lib/active_record/attribute_assignment.rb#L83
81
+ def find_parameter_position(multiparameter_name)
82
+ multiparameter_name.scan(/\(([0-9]*).*\)/).first.first.to_i
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,215 @@
1
+ require 'attr_json/nested_attributes/multiparameter_attribute_writer'
2
+
3
+ module AttrJson
4
+ module NestedAttributes
5
+ # Implementation of `assign_nested_attributes` methods, called by the model
6
+ # method of that name that {NestedAttributes} adds.
7
+ class Writer
8
+ attr_reader :model, :attr_name, :attr_def
9
+
10
+ def initialize(model, attr_name)
11
+ @model, @attr_name = model, attr_name
12
+ @attr_def = model.class.attr_json_registry.fetch(attr_name)
13
+ end
14
+
15
+ delegate :nested_attributes_options, to: :model
16
+
17
+ def assign_nested_attributes(attributes)
18
+ if attr_def.array_type?
19
+ assign_nested_attributes_for_model_array(attributes)
20
+ else
21
+ assign_nested_attributes_for_single_model(attributes)
22
+ end
23
+ end
24
+
25
+ protected
26
+
27
+ def model_send(method, *args)
28
+ model.send(method, *args)
29
+ end
30
+
31
+ def unassignable_keys
32
+ if model.class.const_defined?(:UNASSIGNABLE_KEYS)
33
+ # https://github.com/rails/rails/blob/a45f234b028fd4dda5338e5073a3bf2b8bf2c6fd/activerecord/lib/active_record/nested_attributes.rb#L392
34
+ (model.class)::UNASSIGNABLE_KEYS
35
+ else
36
+ # No need to mark "id" as unassignable in our AttrJson::Model-based nested models
37
+ ["_destroy"]
38
+ end
39
+ end
40
+
41
+ # Copied with signficant modifications from:
42
+ # https://github.com/rails/rails/blob/master/activerecord/lib/active_record/nested_attributes.rb#L407
43
+ def assign_nested_attributes_for_single_model(attributes)
44
+ options = nested_attributes_options[attr_name]
45
+ if attributes.respond_to?(:permitted?)
46
+ attributes = attributes.to_h
47
+ end
48
+ attributes = attributes.with_indifferent_access
49
+
50
+ existing_record = model_send(attr_name)
51
+
52
+ if existing_record && has_destroy_flag?(attributes)
53
+ # We don't have mark_for_destroy like in AR we just
54
+ # set it to nil to eliminate it in the AttrJson, that's it.
55
+ model_send("#{attr_def.name}=", nil)
56
+
57
+ return model
58
+ end
59
+
60
+ multi_parameter_attributes = extract_multi_parameter_attributes(attributes)
61
+
62
+ if existing_record
63
+ existing_record.assign_attributes(attributes.except(*unassignable_keys))
64
+ elsif !reject_new_record?(attr_name, attributes)
65
+ # doesn't exist yet, using the setter casting will build it for us
66
+ # automatically.
67
+ model_send("#{attr_name}=", attributes.except(*unassignable_keys))
68
+ end
69
+
70
+ if multi_parameter_attributes.present?
71
+ MultiparameterAttributeWriter.new(
72
+ model.send(attr_name)
73
+ ).assign_multiparameter_attributes(multi_parameter_attributes)
74
+ end
75
+
76
+ return model
77
+ end
78
+
79
+ # Copied with significant modification from
80
+ # https://github.com/rails/rails/blob/master/activerecord/lib/active_record/nested_attributes.rb#L466
81
+ def assign_nested_attributes_for_model_array(attributes_collection)
82
+ options = nested_attributes_options[attr_name]
83
+
84
+ unless attributes_collection.is_a?(Hash) || attributes_collection.is_a?(Array)
85
+ raise ArgumentError, "Hash or Array expected, got #{attributes_collection.class.name} (#{attributes_collection.inspect})"
86
+ end
87
+
88
+ check_record_limit!(options[:limit], attributes_collection)
89
+
90
+ # Dunno what this is about but it's from ActiveRecord::NestedAttributes
91
+ if attributes_collection.is_a? Hash
92
+ keys = attributes_collection.keys
93
+ attributes_collection = if keys.include?("id") || keys.include?(:id)
94
+ [attributes_collection]
95
+ else
96
+ attributes_collection.values
97
+ end
98
+ end
99
+
100
+ if attributes_collection.respond_to?(:permitted?)
101
+ attributes_collection = attributes_collection.to_h
102
+ end
103
+ attributes_collection.collect!(&:stringify_keys)
104
+
105
+ # remove ones marked with _destroy key, or rejected
106
+ attributes_collection = attributes_collection.reject do |hash|
107
+ hash.respond_to?(:[]) && (has_destroy_flag?(hash) || reject_new_record?(attr_name, hash))
108
+ end
109
+
110
+ attributes_collection.collect! { |h| h.except(*unassignable_keys) }
111
+
112
+ multi_param_attr_array = attributes_collection.collect do |hash|
113
+ extract_multi_parameter_attributes(hash)
114
+ end
115
+
116
+ # the magic of our type casting, this should 'just work', we'll have
117
+ # a NEW array of models, unlike AR we don't re-use existing nested models
118
+ # on assignment.
119
+ model_send("#{attr_name}=", attributes_collection)
120
+
121
+ multi_param_attr_array.each_with_index do |multi_param_attrs, i|
122
+ unless multi_param_attrs.empty?
123
+ MultiparameterAttributeWriter.new(
124
+ model_send(attr_name)[i]
125
+ ).assign_multiparameter_attributes(multi_param_attrs)
126
+ end
127
+ end
128
+
129
+ return model
130
+ end
131
+
132
+ # Copied from ActiveRecord::NestedAttributes:
133
+ #
134
+ # Determines if a record with the particular +attributes+ should be
135
+ # rejected by calling the reject_if Symbol or Proc (if defined).
136
+ # The reject_if option is defined by +accepts_nested_attributes_for+.
137
+ #
138
+ # Returns false if there is a +destroy_flag+ on the attributes.
139
+ def call_reject_if(association_name, attributes)
140
+ return false if will_be_destroyed?(association_name, attributes)
141
+
142
+ case callback = nested_attributes_options[association_name][:reject_if]
143
+ when Symbol
144
+ method(callback).arity == 0 ? send(callback) : send(callback, attributes)
145
+ when Proc
146
+ callback.call(attributes)
147
+ end
148
+ end
149
+
150
+ # Copied from ActiveRecord::NestedAttributes unaltered.
151
+ #
152
+ # Determines if a hash contains a truthy _destroy key.
153
+ def has_destroy_flag?(hash)
154
+ ActiveModel::Type::Boolean.new.cast(hash["_destroy"])
155
+ end
156
+
157
+ # Copied from ActiveRecord::NestedAttributes
158
+ #
159
+ # Takes in a limit and checks if the attributes_collection has too many
160
+ # records. It accepts limit in the form of symbol, proc, or
161
+ # number-like object (anything that can be compared with an integer).
162
+ #
163
+ # Raises TooManyRecords error if the attributes_collection is
164
+ # larger than the limit.
165
+ def check_record_limit!(limit, attributes_collection)
166
+ if limit
167
+ limit = \
168
+ case limit
169
+ when Symbol
170
+ model.send(limit)
171
+ when Proc
172
+ limit.call
173
+ else
174
+ limit
175
+ end
176
+
177
+ if limit && attributes_collection.size > limit
178
+ raise ActiveRecord::NestedAttributes::TooManyRecords, "Maximum #{limit} records are allowed. Got #{attributes_collection.size} records instead."
179
+ end
180
+ end
181
+ end
182
+
183
+ # Copied from ActiveRecord::NestedAttributes
184
+ #
185
+ # Determines if a new record should be rejected by checking
186
+ # has_destroy_flag? or if a <tt>:reject_if</tt> proc exists for this
187
+ # association and evaluates to +true+.
188
+ def reject_new_record?(association_name, attributes)
189
+ will_be_destroyed?(association_name, attributes) || call_reject_if(association_name, attributes)
190
+ end
191
+
192
+ # Unlike ActiveRecord, we don't have an allow_destroy option, so
193
+ # this is just `has_destroy_flag?`
194
+ def will_be_destroyed?(association_name, attributes)
195
+ has_destroy_flag?(attributes)
196
+ end
197
+
198
+ # mutates attributes passsed in to remove multiparameter attributes,
199
+ # and returns multiparam in their own hash. Based on:
200
+ # https://github.com/rails/rails/blob/42a16a4d6514f28e05f1c22a5f9125d194d9c7cb/activerecord/lib/active_record/attribute_assignment.rb#L15-L25
201
+ # See AttrJson::NestedAttributes::MultiparameterAttributeWriter
202
+ def extract_multi_parameter_attributes(attributes)
203
+ multi_parameter_attributes = {}
204
+
205
+ attributes.each do |k, v|
206
+ if k.include?("(")
207
+ multi_parameter_attributes[k] = attributes.delete(k)
208
+ end
209
+ end
210
+
211
+ return multi_parameter_attributes
212
+ end
213
+ end
214
+ end
215
+ end