attr_json 0.1.0

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