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.
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Voids
4
+ VERSION = '2.0.0'
5
+ 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
@@ -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