voids 2.0.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.
- checksums.yaml +7 -0
- data/.circleci/config.yml +11 -0
- data/.rspec +2 -0
- data/.rubocop.yml +356 -0
- data/.rubocop_todo.yml +126 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +427 -0
- data/Rakefile +8 -0
- data/examples/advanced_features_example.rb +81 -0
- data/examples/assign_from_model_example.rb +54 -0
- data/examples/model_name_example.rb +31 -0
- data/examples/normalization_example.rb +39 -0
- data/examples/post_form_example.rb +52 -0
- data/lib/voids/association_proxy.rb +91 -0
- data/lib/voids/associations.rb +59 -0
- data/lib/voids/base.rb +252 -0
- data/lib/voids/model_naming.rb +25 -0
- data/lib/voids/nested_attributes.rb +128 -0
- data/lib/voids/normalization.rb +39 -0
- data/lib/voids/version.rb +5 -0
- data/lib/voids.rb +17 -0
- data/spec/spec_helper.rb +25 -0
- data/spec/voids/association_proxy_spec.rb +213 -0
- data/spec/voids/associations_spec.rb +185 -0
- data/spec/voids/attributes_extraction_spec.rb +138 -0
- data/spec/voids/base_spec.rb +361 -0
- data/spec/voids/callbacks_spec.rb +60 -0
- data/spec/voids/custom_primary_key_spec.rb +168 -0
- data/spec/voids/dirty_tracking_spec.rb +61 -0
- data/spec/voids/i18n_spec.rb +33 -0
- data/spec/voids/id_tracking_spec.rb +168 -0
- data/spec/voids/inherit_attributes_from_spec.rb +149 -0
- data/spec/voids/inherit_validations_from_spec.rb +260 -0
- data/spec/voids/model_naming_spec.rb +82 -0
- data/spec/voids/nested_attributes_spec.rb +377 -0
- data/spec/voids/normalization_spec.rb +122 -0
- metadata +125 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Voids
|
|
4
|
+
# Array-like proxy for form associations.
|
|
5
|
+
class AssociationProxy
|
|
6
|
+
include Enumerable
|
|
7
|
+
|
|
8
|
+
def initialize(class_name)
|
|
9
|
+
@class_name = class_name
|
|
10
|
+
@records = []
|
|
11
|
+
@records_by_id = {}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def new(attributes = {})
|
|
15
|
+
form_class = @class_name.constantize
|
|
16
|
+
instance = form_class.new(attributes)
|
|
17
|
+
push(instance)
|
|
18
|
+
instance
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def build(attributes = {})
|
|
22
|
+
new(attributes)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def find_by_id(id)
|
|
26
|
+
@records_by_id[id.to_s]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def find_by(attribute, value)
|
|
30
|
+
return @records_by_id[value.to_s] if attribute.to_s == 'id' && @records_by_id.key?(value.to_s)
|
|
31
|
+
|
|
32
|
+
@records.find do |record|
|
|
33
|
+
record.respond_to?(attribute) && record.public_send(attribute).to_s == value.to_s
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def push(record)
|
|
38
|
+
@records << record
|
|
39
|
+
@records_by_id[record.id.to_s] = record if record.respond_to?(:id) && record.id.present?
|
|
40
|
+
self
|
|
41
|
+
end
|
|
42
|
+
alias << push
|
|
43
|
+
|
|
44
|
+
def each(&)
|
|
45
|
+
@records.each(&)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def size
|
|
49
|
+
@records.size
|
|
50
|
+
end
|
|
51
|
+
alias length size
|
|
52
|
+
alias count size
|
|
53
|
+
|
|
54
|
+
def empty?
|
|
55
|
+
@records.empty?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def any?
|
|
59
|
+
@records.any?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def [](index)
|
|
63
|
+
@records[index]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def clear
|
|
67
|
+
@records.clear
|
|
68
|
+
@records_by_id.clear
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def to_a
|
|
72
|
+
@records
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def to_ary
|
|
76
|
+
@records
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def persisted?
|
|
80
|
+
false
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def valid?
|
|
84
|
+
@records.all?(&:valid?)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def errors
|
|
88
|
+
@records.flat_map(&:errors)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Voids
|
|
4
|
+
# Association declarations for form objects (`has_one` / `has_many`)
|
|
5
|
+
module Associations
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
class_attribute :associations, default: {}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class_methods do
|
|
13
|
+
def has_one(name, class_name: nil, primary_key: :id, **)
|
|
14
|
+
association_class_name = class_name || "#{name.to_s.camelize}Form"
|
|
15
|
+
|
|
16
|
+
self.associations = associations.merge(
|
|
17
|
+
name.to_s => { type: :has_one, class_name: association_class_name, primary_key: primary_key.to_s }
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
attr_reader name
|
|
21
|
+
|
|
22
|
+
define_method("#{name}=") do |value|
|
|
23
|
+
instance_variable_set("@#{name}", value)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
define_method("build_#{name}") do |attributes = {}|
|
|
27
|
+
form_class = association_class_name.constantize
|
|
28
|
+
instance = form_class.new(attributes)
|
|
29
|
+
instance_variable_set("@#{name}", instance)
|
|
30
|
+
instance
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
accepts_nested_attributes_for(name, primary_key: primary_key, **)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def has_many(name, class_name: nil, primary_key: :id, **)
|
|
37
|
+
association_class_name = class_name || "#{name.to_s.singularize.camelize}Form"
|
|
38
|
+
|
|
39
|
+
self.associations = associations.merge(
|
|
40
|
+
name.to_s => { type: :has_many, class_name: association_class_name, primary_key: primary_key.to_s }
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
define_method(name) do
|
|
44
|
+
ivar = "@#{name}"
|
|
45
|
+
unless instance_variable_defined?(ivar)
|
|
46
|
+
instance_variable_set(ivar, AssociationProxy.new(association_class_name))
|
|
47
|
+
end
|
|
48
|
+
instance_variable_get(ivar)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
define_method("#{name}=") do |value|
|
|
52
|
+
instance_variable_set("@#{name}", value)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
accepts_nested_attributes_for(name, primary_key: primary_key, **)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
data/lib/voids/base.rb
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Voids
|
|
4
|
+
# Main class to be subclassed by collaborators
|
|
5
|
+
class Base
|
|
6
|
+
include ActiveModel::Model
|
|
7
|
+
include ActiveModel::Attributes
|
|
8
|
+
include ActiveModel::Validations
|
|
9
|
+
include ActiveModel::Dirty
|
|
10
|
+
include ActiveModel::Callbacks
|
|
11
|
+
include Voids::ModelNaming
|
|
12
|
+
include Voids::Normalization
|
|
13
|
+
include Voids::Associations
|
|
14
|
+
include Voids::NestedAttributes
|
|
15
|
+
|
|
16
|
+
define_model_callbacks :validation
|
|
17
|
+
|
|
18
|
+
def self.inherit_attributes_from(model_class, only: nil, except: nil)
|
|
19
|
+
raise ArgumentError, 'cannot specify both :only and :except' if only && except
|
|
20
|
+
|
|
21
|
+
attribute_names = if model_class.respond_to?(:attribute_types)
|
|
22
|
+
model_class.attribute_types.keys
|
|
23
|
+
elsif model_class.respond_to?(:columns)
|
|
24
|
+
model_class.columns.map(&:name)
|
|
25
|
+
else
|
|
26
|
+
raise ArgumentError, "#{model_class} does not respond to :attribute_types or :columns"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
attribute_names = Array(only).map(&:to_s) if only
|
|
30
|
+
attribute_names -= Array(except).map(&:to_s) if except
|
|
31
|
+
|
|
32
|
+
attribute_names.each do |attr_name|
|
|
33
|
+
next if attribute_types.key?(attr_name)
|
|
34
|
+
|
|
35
|
+
attr_type = if model_class.respond_to?(:attribute_types)
|
|
36
|
+
model_class.attribute_types[attr_name]
|
|
37
|
+
elsif model_class.respond_to?(:columns)
|
|
38
|
+
column = model_class.columns.find { |c| c.name == attr_name }
|
|
39
|
+
column&.type
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
attribute attr_name.to_sym, attr_type&.type || :value
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.inherit_validations_from(model_class, only: nil, except: nil)
|
|
47
|
+
raise ArgumentError, 'cannot specify both :only and :except' if only && except
|
|
48
|
+
|
|
49
|
+
model_class.validators.each do |validator|
|
|
50
|
+
next if skip_validator?(validator)
|
|
51
|
+
|
|
52
|
+
attrs = validator.attributes.dup
|
|
53
|
+
attrs &= Array(only).map(&:to_sym) if only
|
|
54
|
+
attrs -= Array(except).map(&:to_sym) if except
|
|
55
|
+
next if attrs.empty?
|
|
56
|
+
|
|
57
|
+
options = validator.options.except(:class)
|
|
58
|
+
kind = validator.kind
|
|
59
|
+
|
|
60
|
+
if options.empty?
|
|
61
|
+
validates(*attrs, kind => true)
|
|
62
|
+
else
|
|
63
|
+
validates(*attrs, kind => options)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.skip_validator?(validator)
|
|
69
|
+
if defined?(ActiveRecord::Validations::AssociatedValidator) && validator.is_a?(ActiveRecord::Validations::AssociatedValidator)
|
|
70
|
+
return true
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
opts = validator.options
|
|
74
|
+
%i[if unless].any? { |key| opts[key].is_a?(Proc) }
|
|
75
|
+
end
|
|
76
|
+
private_class_method :skip_validator?
|
|
77
|
+
|
|
78
|
+
def self.from_model(model)
|
|
79
|
+
instance = new
|
|
80
|
+
instance.from_model(model)
|
|
81
|
+
instance
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def initialize(attributes = {})
|
|
85
|
+
super()
|
|
86
|
+
@marked_for_destruction = false
|
|
87
|
+
assign_attributes(attributes) if attributes.present?
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def assign_attributes(new_attributes)
|
|
91
|
+
return if new_attributes.blank?
|
|
92
|
+
|
|
93
|
+
attrs = if new_attributes.respond_to?(:to_unsafe_h)
|
|
94
|
+
new_attributes.to_unsafe_h.stringify_keys
|
|
95
|
+
elsif new_attributes.respond_to?(:to_h)
|
|
96
|
+
new_attributes.to_h.stringify_keys
|
|
97
|
+
else
|
|
98
|
+
new_attributes.stringify_keys
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
attrs.each do |key, value|
|
|
102
|
+
if key.end_with?('_attributes')
|
|
103
|
+
association_name = key.delete_suffix('_attributes')
|
|
104
|
+
send("#{association_name}_attributes=", value)
|
|
105
|
+
elsif key == '_destroy'
|
|
106
|
+
self._destroy = value
|
|
107
|
+
else
|
|
108
|
+
public_send("#{key}=", value)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
changes_applied
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def from_model(model)
|
|
116
|
+
return self if model.nil?
|
|
117
|
+
|
|
118
|
+
self.class.attribute_names.each do |attr_name|
|
|
119
|
+
public_send("#{attr_name}=", model.public_send(attr_name)) if model.respond_to?(attr_name)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
self.class.associations.each do |name, association|
|
|
123
|
+
next unless model.respond_to?(name)
|
|
124
|
+
|
|
125
|
+
associated_value = model.public_send(name)
|
|
126
|
+
next if associated_value.nil?
|
|
127
|
+
|
|
128
|
+
case association[:type]
|
|
129
|
+
when :has_one
|
|
130
|
+
form_instance = association[:class_name].constantize.new
|
|
131
|
+
form_instance.from_model(associated_value)
|
|
132
|
+
public_send("#{name}=", form_instance)
|
|
133
|
+
when :has_many
|
|
134
|
+
associated_value.each do |record|
|
|
135
|
+
form_instance = association[:class_name].constantize.new
|
|
136
|
+
form_instance.from_model(record)
|
|
137
|
+
public_send(name).push(form_instance)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
self
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def persisted?
|
|
146
|
+
respond_to?(:id) && id.present?
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def to_key
|
|
150
|
+
persisted? ? [id] : nil
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def to_param
|
|
154
|
+
persisted? ? id.to_s : nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def to_model
|
|
158
|
+
self
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def marked_for_destruction?
|
|
162
|
+
@marked_for_destruction
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def mark_for_destruction
|
|
166
|
+
@marked_for_destruction = true
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def _destroy
|
|
170
|
+
@marked_for_destruction
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def _destroy=(value)
|
|
174
|
+
@marked_for_destruction = ActiveModel::Type::Boolean.new.cast(value)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def valid?(context = nil)
|
|
178
|
+
run_callbacks :validation do
|
|
179
|
+
super(context) && nested_forms_valid?
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def model_attributes
|
|
184
|
+
attribute_names.to_h do |name|
|
|
185
|
+
[name, public_send(name)]
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def attributes
|
|
190
|
+
attrs = model_attributes.dup
|
|
191
|
+
|
|
192
|
+
self.class.associations.each do |name, association|
|
|
193
|
+
nested_form = public_send(name)
|
|
194
|
+
next if nested_form.nil?
|
|
195
|
+
|
|
196
|
+
case association[:type]
|
|
197
|
+
when :has_one
|
|
198
|
+
nested_attrs = nested_form.attributes
|
|
199
|
+
nested_attrs['_destroy'] = true if nested_form.marked_for_destruction?
|
|
200
|
+
attrs["#{name}_attributes"] = nested_attrs
|
|
201
|
+
when :has_many
|
|
202
|
+
attrs["#{name}_attributes"] = nested_form.map do |form|
|
|
203
|
+
form_attrs = form.attributes
|
|
204
|
+
form_attrs['_destroy'] = true if form.marked_for_destruction?
|
|
205
|
+
form_attrs
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
attrs
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def assignable_attributes(exclude: [:id])
|
|
214
|
+
excluded_keys = Array(exclude).map(&:to_s)
|
|
215
|
+
attributes.except(*excluded_keys)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
private
|
|
219
|
+
|
|
220
|
+
def nested_forms_valid?
|
|
221
|
+
self.class.associations.all? do |name, association|
|
|
222
|
+
nested_form = public_send(name)
|
|
223
|
+
next true if nested_form.nil?
|
|
224
|
+
|
|
225
|
+
case association[:type]
|
|
226
|
+
when :has_one
|
|
227
|
+
if nested_form.valid?
|
|
228
|
+
true
|
|
229
|
+
else
|
|
230
|
+
copy_nested_errors(name, nested_form)
|
|
231
|
+
false
|
|
232
|
+
end
|
|
233
|
+
when :has_many
|
|
234
|
+
if nested_form.valid?
|
|
235
|
+
true
|
|
236
|
+
else
|
|
237
|
+
nested_form.each_with_index do |form, index|
|
|
238
|
+
copy_nested_errors("#{name}[#{index}]", form) unless form.valid?
|
|
239
|
+
end
|
|
240
|
+
false
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def copy_nested_errors(association_name, nested_form)
|
|
247
|
+
nested_form.errors.each do |error|
|
|
248
|
+
errors.add("#{association_name}.#{error.attribute}", error.message)
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Voids
|
|
4
|
+
# Allows form objects to override `model_name` for Rails helpers.
|
|
5
|
+
module ModelNaming
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
class_attribute :_model_name_override
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class_methods do
|
|
13
|
+
def model_name
|
|
14
|
+
return _model_name_override if _model_name_override
|
|
15
|
+
|
|
16
|
+
name_without_form = name.sub(/Form$/, '')
|
|
17
|
+
ActiveModel::Name.new(self, nil, name_without_form)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def model_name_for(name)
|
|
21
|
+
self._model_name_override = ActiveModel::Name.new(self, nil, name.to_s.camelize)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Voids
|
|
4
|
+
# A lightweight `accepts_nested_attributes_for` implementation for Voids forms.
|
|
5
|
+
module NestedAttributes
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
class_attribute :nested_attributes_options, default: {}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class_methods do
|
|
13
|
+
def accepts_nested_attributes_for(*attr_names)
|
|
14
|
+
options = attr_names.extract_options!
|
|
15
|
+
|
|
16
|
+
attr_names.each do |association_name|
|
|
17
|
+
self.nested_attributes_options = nested_attributes_options.merge(
|
|
18
|
+
association_name.to_s => options
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
define_nested_attributes_method(association_name)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def define_nested_attributes_method(association_name)
|
|
28
|
+
define_method("#{association_name}_attributes=") do |attributes|
|
|
29
|
+
association = self.class.associations[association_name.to_s]
|
|
30
|
+
|
|
31
|
+
raise ArgumentError, "No association found for name '#{association_name}'" unless association
|
|
32
|
+
|
|
33
|
+
case association[:type]
|
|
34
|
+
when :has_one
|
|
35
|
+
assign_nested_attributes_for_one_to_one_association(association_name, attributes)
|
|
36
|
+
when :has_many
|
|
37
|
+
assign_nested_attributes_for_collection_association(association_name, attributes)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def assign_nested_attributes_for_one_to_one_association(association_name, attributes)
|
|
44
|
+
return if attributes.blank?
|
|
45
|
+
|
|
46
|
+
association = self.class.associations[association_name.to_s]
|
|
47
|
+
options = self.class.nested_attributes_options[association_name.to_s] || {}
|
|
48
|
+
form_class = association[:class_name].constantize
|
|
49
|
+
attrs_hash = attributes.stringify_keys
|
|
50
|
+
|
|
51
|
+
if has_destroy_flag?(attrs_hash) && options[:allow_destroy]
|
|
52
|
+
instance = public_send(association_name)
|
|
53
|
+
instance&.mark_for_destruction
|
|
54
|
+
return
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
instance = public_send(association_name)
|
|
58
|
+
attrs_without_destroy = attrs_hash.except('_destroy')
|
|
59
|
+
if instance
|
|
60
|
+
instance.assign_attributes(attrs_without_destroy)
|
|
61
|
+
else
|
|
62
|
+
instance = form_class.new(attrs_without_destroy)
|
|
63
|
+
public_send("#{association_name}=", instance)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def assign_nested_attributes_for_collection_association(association_name, attributes)
|
|
68
|
+
return if attributes.blank?
|
|
69
|
+
|
|
70
|
+
attributes_collection = attributes.is_a?(Hash) ? attributes.values : attributes
|
|
71
|
+
association = self.class.associations[association_name.to_s]
|
|
72
|
+
options = self.class.nested_attributes_options[association_name.to_s] || {}
|
|
73
|
+
association[:class_name].constantize
|
|
74
|
+
collection = public_send(association_name)
|
|
75
|
+
primary_key = (options[:primary_key] || association[:primary_key] || 'id').to_s
|
|
76
|
+
|
|
77
|
+
attributes_collection.each do |attrs|
|
|
78
|
+
next if call_reject_if(association_name, attrs)
|
|
79
|
+
|
|
80
|
+
attrs_hash = attrs.is_a?(Hash) ? attrs.stringify_keys : attrs
|
|
81
|
+
|
|
82
|
+
if has_destroy_flag?(attrs_hash)
|
|
83
|
+
if options[:allow_destroy] && attrs_hash[primary_key].present?
|
|
84
|
+
existing = collection.find_by(primary_key, attrs_hash[primary_key])
|
|
85
|
+
if existing
|
|
86
|
+
existing.mark_for_destruction
|
|
87
|
+
else
|
|
88
|
+
new_form = collection.new(attrs_hash.except('_destroy'))
|
|
89
|
+
new_form.mark_for_destruction
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
next
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
if attrs_hash[primary_key].present?
|
|
96
|
+
existing = collection.find_by(primary_key, attrs_hash[primary_key])
|
|
97
|
+
if existing
|
|
98
|
+
existing.assign_attributes(attrs_hash.except(primary_key, '_destroy'))
|
|
99
|
+
else
|
|
100
|
+
collection.new(attrs_hash.except('_destroy'))
|
|
101
|
+
end
|
|
102
|
+
else
|
|
103
|
+
collection.new(attrs_hash.except('_destroy'))
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def call_reject_if(association_name, attributes)
|
|
109
|
+
options = self.class.nested_attributes_options[association_name.to_s]
|
|
110
|
+
return false unless options
|
|
111
|
+
|
|
112
|
+
reject_if = options[:reject_if]
|
|
113
|
+
return false unless reject_if
|
|
114
|
+
|
|
115
|
+
if reject_if.is_a?(Symbol)
|
|
116
|
+
method(reject_if).call(attributes)
|
|
117
|
+
else
|
|
118
|
+
reject_if.call(attributes)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def has_destroy_flag?(attributes)
|
|
123
|
+
attrs = attributes.stringify_keys
|
|
124
|
+
value = attrs['_destroy']
|
|
125
|
+
ActiveModel::Type::Boolean.new.cast(value)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Voids
|
|
4
|
+
# Provides attribute writer normalization via `normalizes`.
|
|
5
|
+
module Normalization
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
class_attribute :_normalizations, instance_writer: false, default: {}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class_methods do
|
|
13
|
+
def normalizes(*names, with:, apply_to_nil: false)
|
|
14
|
+
names.each do |name|
|
|
15
|
+
_normalizations[name.to_s] = { normalizer: with, apply_to_nil: apply_to_nil }
|
|
16
|
+
|
|
17
|
+
define_method("#{name}=") do |value|
|
|
18
|
+
normalized_value = self.class.normalize_value_for(name, value)
|
|
19
|
+
super(normalized_value)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def normalize_value_for(name, value)
|
|
25
|
+
normalization = _normalizations[name.to_s]
|
|
26
|
+
return value unless normalization
|
|
27
|
+
|
|
28
|
+
return value if value.nil? && !normalization[:apply_to_nil]
|
|
29
|
+
|
|
30
|
+
normalizer = normalization[:normalizer]
|
|
31
|
+
if normalizer.respond_to?(:call)
|
|
32
|
+
normalizer.call(value)
|
|
33
|
+
else
|
|
34
|
+
value.public_send(normalizer)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/voids.rb
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_model'
|
|
4
|
+
require 'active_support/concern'
|
|
5
|
+
require 'active_support/core_ext/string/inflections'
|
|
6
|
+
require 'active_support/core_ext/array/extract_options'
|
|
7
|
+
require 'active_support/core_ext/object/blank'
|
|
8
|
+
require 'zeitwerk'
|
|
9
|
+
|
|
10
|
+
# Autoload gem internals.
|
|
11
|
+
Zeitwerk::Loader.for_gem.setup
|
|
12
|
+
|
|
13
|
+
# Top-level module
|
|
14
|
+
module Voids
|
|
15
|
+
# Custom exception wrapper
|
|
16
|
+
class Error < StandardError; end
|
|
17
|
+
end
|
data/spec/spec_helper.rb
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'simplecov'
|
|
4
|
+
|
|
5
|
+
SimpleCov.start do
|
|
6
|
+
skip 'spec'
|
|
7
|
+
|
|
8
|
+
if ENV['CI']
|
|
9
|
+
require 'simplecov_json_formatter'
|
|
10
|
+
|
|
11
|
+
formatter SimpleCov::Formatter::JSONFormatter
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
require 'voids'
|
|
16
|
+
|
|
17
|
+
RSpec.configure do |config|
|
|
18
|
+
config.example_status_persistence_file_path = '.rspec_status'
|
|
19
|
+
|
|
20
|
+
config.disable_monkey_patching!
|
|
21
|
+
|
|
22
|
+
config.expect_with :rspec do |c|
|
|
23
|
+
c.syntax = :expect
|
|
24
|
+
end
|
|
25
|
+
end
|