blanks 1.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.
Files changed (92) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +427 -0
  5. data/Rakefile +8 -0
  6. data/examples/advanced_features_example.rb +81 -0
  7. data/examples/assign_from_model_example.rb +54 -0
  8. data/examples/model_name_example.rb +31 -0
  9. data/examples/normalization_example.rb +39 -0
  10. data/examples/post_form_example.rb +52 -0
  11. data/lib/blanks/association_proxy.rb +92 -0
  12. data/lib/blanks/associations.rb +58 -0
  13. data/lib/blanks/base.rb +256 -0
  14. data/lib/blanks/model_naming.rb +24 -0
  15. data/lib/blanks/nested_attributes.rb +131 -0
  16. data/lib/blanks/normalization.rb +38 -0
  17. data/lib/blanks/version.rb +3 -0
  18. data/lib/blanks.rb +18 -0
  19. data/spec/blanks/association_proxy_spec.rb +214 -0
  20. data/spec/blanks/associations_spec.rb +185 -0
  21. data/spec/blanks/attributes_extraction_spec.rb +138 -0
  22. data/spec/blanks/base_spec.rb +361 -0
  23. data/spec/blanks/callbacks_spec.rb +60 -0
  24. data/spec/blanks/custom_primary_key_spec.rb +168 -0
  25. data/spec/blanks/dirty_tracking_spec.rb +61 -0
  26. data/spec/blanks/i18n_spec.rb +33 -0
  27. data/spec/blanks/id_tracking_spec.rb +168 -0
  28. data/spec/blanks/inherit_attributes_from_spec.rb +148 -0
  29. data/spec/blanks/inherit_validations_from_spec.rb +260 -0
  30. data/spec/blanks/model_naming_spec.rb +82 -0
  31. data/spec/blanks/nested_attributes_spec.rb +378 -0
  32. data/spec/blanks/normalization_spec.rb +122 -0
  33. data/spec/dummy/Gemfile +10 -0
  34. data/spec/dummy/Gemfile.lock +242 -0
  35. data/spec/dummy/Rakefile +5 -0
  36. data/spec/dummy/app/controllers/application_controller.rb +4 -0
  37. data/spec/dummy/app/controllers/simple_form/articles_controller.rb +65 -0
  38. data/spec/dummy/app/controllers/simple_form/posts_controller.rb +59 -0
  39. data/spec/dummy/app/controllers/standard/articles_controller.rb +65 -0
  40. data/spec/dummy/app/controllers/standard/posts_controller.rb +59 -0
  41. data/spec/dummy/app/forms/article_form.rb +17 -0
  42. data/spec/dummy/app/forms/cover_image_form.rb +9 -0
  43. data/spec/dummy/app/forms/post_form.rb +10 -0
  44. data/spec/dummy/app/forms/tag_form.rb +12 -0
  45. data/spec/dummy/app/models/application_record.rb +5 -0
  46. data/spec/dummy/app/models/article.rb +11 -0
  47. data/spec/dummy/app/models/cover_image.rb +7 -0
  48. data/spec/dummy/app/models/post.rb +8 -0
  49. data/spec/dummy/app/models/tag.rb +7 -0
  50. data/spec/dummy/app/views/layouts/application.html.erb +43 -0
  51. data/spec/dummy/app/views/simple_form/articles/_form.html.erb +43 -0
  52. data/spec/dummy/app/views/simple_form/articles/edit.html.erb +5 -0
  53. data/spec/dummy/app/views/simple_form/articles/index.html.erb +29 -0
  54. data/spec/dummy/app/views/simple_form/articles/new.html.erb +5 -0
  55. data/spec/dummy/app/views/simple_form/articles/show.html.erb +29 -0
  56. data/spec/dummy/app/views/simple_form/posts/_form.html.erb +19 -0
  57. data/spec/dummy/app/views/simple_form/posts/edit.html.erb +5 -0
  58. data/spec/dummy/app/views/simple_form/posts/index.html.erb +27 -0
  59. data/spec/dummy/app/views/simple_form/posts/new.html.erb +5 -0
  60. data/spec/dummy/app/views/simple_form/posts/show.html.erb +12 -0
  61. data/spec/dummy/app/views/standard/articles/_form.html.erb +61 -0
  62. data/spec/dummy/app/views/standard/articles/edit.html.erb +5 -0
  63. data/spec/dummy/app/views/standard/articles/index.html.erb +29 -0
  64. data/spec/dummy/app/views/standard/articles/new.html.erb +5 -0
  65. data/spec/dummy/app/views/standard/articles/show.html.erb +29 -0
  66. data/spec/dummy/app/views/standard/posts/_form.html.erb +30 -0
  67. data/spec/dummy/app/views/standard/posts/edit.html.erb +5 -0
  68. data/spec/dummy/app/views/standard/posts/index.html.erb +27 -0
  69. data/spec/dummy/app/views/standard/posts/new.html.erb +5 -0
  70. data/spec/dummy/app/views/standard/posts/show.html.erb +12 -0
  71. data/spec/dummy/bin/rails +6 -0
  72. data/spec/dummy/config/application.rb +18 -0
  73. data/spec/dummy/config/boot.rb +5 -0
  74. data/spec/dummy/config/database.yml +12 -0
  75. data/spec/dummy/config/environment.rb +5 -0
  76. data/spec/dummy/config/environments/development.rb +9 -0
  77. data/spec/dummy/config/environments/test.rb +8 -0
  78. data/spec/dummy/config/initializers/simple_form.rb +21 -0
  79. data/spec/dummy/config/routes.rb +15 -0
  80. data/spec/dummy/config/storage.yml +3 -0
  81. data/spec/dummy/config.ru +5 -0
  82. data/spec/dummy/db/migrate/1_create_posts.rb +12 -0
  83. data/spec/dummy/db/migrate/2_create_articles.rb +12 -0
  84. data/spec/dummy/db/migrate/3_create_cover_images.rb +12 -0
  85. data/spec/dummy/db/migrate/4_create_tags.rb +12 -0
  86. data/spec/dummy/db/migrate/5_create_active_storage_tables.rb +36 -0
  87. data/spec/dummy/db/schema.rb +82 -0
  88. data/spec/dummy/spec/examples.txt +145 -0
  89. data/spec/dummy/tmp/local_secret.txt +1 -0
  90. data/spec/examples.txt +157 -0
  91. data/spec/spec_helper.rb +21 -0
  92. metadata +159 -0
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "blanks"
4
+
5
+ class ImageForm < Blanks::Base
6
+ attribute :url, :string
7
+ attribute :caption, :string
8
+
9
+ validates :url, presence: true
10
+ end
11
+
12
+ class CoverPhotoForm < Blanks::Base
13
+ attribute :url, :string
14
+
15
+ validates :url, presence: true
16
+ end
17
+
18
+ class PostForm < Blanks::Base
19
+ has_one :cover_photo
20
+ has_many :images
21
+
22
+ attribute :title, :string
23
+ attribute :content, :string
24
+ attribute :created_at, :datetime, default: -> { Time.current }
25
+
26
+ validates :title, presence: true
27
+ validates :content, presence: true
28
+ validates :created_at, presence: true
29
+ end
30
+
31
+ form = PostForm.new
32
+ puts "empty form valid: #{form.valid?}"
33
+
34
+ form = PostForm.new(
35
+ title: "hello world",
36
+ content: "this is content",
37
+ created_at: Time.now,
38
+ cover_photo_attributes: { url: "https://example.com/cover.jpg" },
39
+ images_attributes: [
40
+ { url: "https://example.com/1.jpg", caption: "first" },
41
+ { url: "https://example.com/2.jpg", caption: "second" }
42
+ ]
43
+ )
44
+
45
+ puts "form with attributes valid: #{form.valid?}"
46
+ puts "title: #{form.title}"
47
+ puts "cover photo url: #{form.cover_photo.url}"
48
+ puts "images count: #{form.images.count}"
49
+
50
+ form = PostForm.new
51
+ form.images.new(url: "https://example.com/new.jpg", caption: "new image")
52
+ puts "form with new image count: #{form.images.count}"
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Blanks
4
+ class AssociationProxy
5
+ include Enumerable
6
+
7
+ def initialize(class_name)
8
+ @class_name = class_name
9
+ @records = []
10
+ @records_by_id = {}
11
+ end
12
+
13
+ def new(attributes = {})
14
+ form_class = @class_name.constantize
15
+ instance = form_class.new(attributes)
16
+ push(instance)
17
+ instance
18
+ end
19
+
20
+ def build(attributes = {})
21
+ new(attributes)
22
+ end
23
+
24
+ def find_by_id(id)
25
+ @records_by_id[id.to_s]
26
+ end
27
+
28
+ def find_by(attribute, value)
29
+ return @records_by_id[value.to_s] if attribute.to_s == "id" && @records_by_id.key?(value.to_s)
30
+
31
+ @records.find do |record|
32
+ record.respond_to?(attribute) && record.public_send(attribute).to_s == value.to_s
33
+ end
34
+ end
35
+
36
+ def push(record)
37
+ @records << record
38
+ if record.respond_to?(:id) && record.id.present?
39
+ @records_by_id[record.id.to_s] = record
40
+ end
41
+ self
42
+ end
43
+ alias_method :<<, :push
44
+
45
+ def each(&block)
46
+ @records.each(&block)
47
+ end
48
+
49
+ def size
50
+ @records.size
51
+ end
52
+ alias_method :length, :size
53
+ alias_method :count, :size
54
+
55
+ def empty?
56
+ @records.empty?
57
+ end
58
+
59
+ def any?
60
+ @records.any?
61
+ end
62
+
63
+ def [](index)
64
+ @records[index]
65
+ end
66
+
67
+ def clear
68
+ @records.clear
69
+ @records_by_id.clear
70
+ end
71
+
72
+ def to_a
73
+ @records
74
+ end
75
+
76
+ def to_ary
77
+ @records
78
+ end
79
+
80
+ def persisted?
81
+ false
82
+ end
83
+
84
+ def valid?
85
+ @records.all?(&:valid?)
86
+ end
87
+
88
+ def errors
89
+ @records.flat_map(&:errors)
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Blanks
4
+ module Associations
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ class_attribute :associations, default: {}
9
+ end
10
+
11
+ class_methods do
12
+ def has_one(name, class_name: nil, primary_key: :id, **options)
13
+ association_class_name = class_name || "#{name.to_s.camelize}Form"
14
+
15
+ self.associations = associations.merge(
16
+ name.to_s => { type: :has_one, class_name: association_class_name, primary_key: primary_key.to_s }
17
+ )
18
+
19
+ attr_reader name
20
+
21
+ define_method("#{name}=") do |value|
22
+ instance_variable_set("@#{name}", value)
23
+ end
24
+
25
+ define_method("build_#{name}") do |attributes = {}|
26
+ form_class = association_class_name.constantize
27
+ instance = form_class.new(attributes)
28
+ instance_variable_set("@#{name}", instance)
29
+ instance
30
+ end
31
+
32
+ accepts_nested_attributes_for(name, primary_key: primary_key, **options)
33
+ end
34
+
35
+ def has_many(name, class_name: nil, primary_key: :id, **options)
36
+ association_class_name = class_name || "#{name.to_s.singularize.camelize}Form"
37
+
38
+ self.associations = associations.merge(
39
+ name.to_s => { type: :has_many, class_name: association_class_name, primary_key: primary_key.to_s }
40
+ )
41
+
42
+ define_method(name) do
43
+ ivar = "@#{name}"
44
+ unless instance_variable_defined?(ivar)
45
+ instance_variable_set(ivar, AssociationProxy.new(association_class_name))
46
+ end
47
+ instance_variable_get(ivar)
48
+ end
49
+
50
+ define_method("#{name}=") do |value|
51
+ instance_variable_set("@#{name}", value)
52
+ end
53
+
54
+ accepts_nested_attributes_for(name, primary_key: primary_key, **options)
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,256 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model"
4
+
5
+ module Blanks
6
+ class Base
7
+ include ActiveModel::Model
8
+ include ActiveModel::Attributes
9
+ include ActiveModel::Validations
10
+ include ActiveModel::Dirty
11
+ include ActiveModel::Callbacks
12
+
13
+ include Blanks::ModelNaming
14
+ include Blanks::Normalization
15
+ include Blanks::Associations
16
+ include Blanks::NestedAttributes
17
+
18
+ define_model_callbacks :validation
19
+
20
+ def self.inherit_attributes_from(model_class, only: nil, except: nil)
21
+ raise ArgumentError, "cannot specify both :only and :except" if only && except
22
+
23
+ attribute_names = if model_class.respond_to?(:attribute_types)
24
+ model_class.attribute_types.keys
25
+ elsif model_class.respond_to?(:columns)
26
+ model_class.columns.map(&:name)
27
+ else
28
+ raise ArgumentError, "#{model_class} does not respond to :attribute_types or :columns"
29
+ end
30
+
31
+ attribute_names = Array(only).map(&:to_s) if only
32
+ attribute_names -= Array(except).map(&:to_s) if except
33
+
34
+ attribute_names.each do |attr_name|
35
+ next if attribute_types.key?(attr_name)
36
+
37
+ attr_type = if model_class.respond_to?(:attribute_types)
38
+ model_class.attribute_types[attr_name]
39
+ elsif model_class.respond_to?(:columns)
40
+ column = model_class.columns.find { |c| c.name == attr_name }
41
+ column&.type
42
+ end
43
+
44
+ attribute attr_name.to_sym, attr_type&.type || :value
45
+ end
46
+ end
47
+
48
+ def self.inherit_validations_from(model_class, only: nil, except: nil)
49
+ raise ArgumentError, "cannot specify both :only and :except" if only && except
50
+
51
+ model_class.validators.each do |validator|
52
+ next if skip_validator?(validator)
53
+
54
+ attrs = validator.attributes.dup
55
+ attrs = attrs & Array(only).map(&:to_sym) if only
56
+ attrs = attrs - Array(except).map(&:to_sym) if except
57
+ next if attrs.empty?
58
+
59
+ options = validator.options.except(:class)
60
+ kind = validator.kind
61
+
62
+ if options.empty?
63
+ validates(*attrs, kind => true)
64
+ else
65
+ validates(*attrs, kind => options)
66
+ end
67
+ end
68
+ end
69
+
70
+ def self.skip_validator?(validator)
71
+ if defined?(ActiveRecord::Validations::AssociatedValidator)
72
+ return true if validator.is_a?(ActiveRecord::Validations::AssociatedValidator)
73
+ end
74
+
75
+ opts = validator.options
76
+ [:if, :unless].any? { |key| opts[key].is_a?(Proc) }
77
+ end
78
+ private_class_method :skip_validator?
79
+
80
+ def self.from_model(model)
81
+ instance = new
82
+ instance.from_model(model)
83
+ instance
84
+ end
85
+
86
+ def initialize(attributes = {})
87
+ super()
88
+ @marked_for_destruction = false
89
+ assign_attributes(attributes) if attributes.present?
90
+ end
91
+
92
+ def assign_attributes(new_attributes)
93
+ return if new_attributes.blank?
94
+
95
+ attrs = if new_attributes.respond_to?(:to_unsafe_h)
96
+ new_attributes.to_unsafe_h.stringify_keys
97
+ elsif new_attributes.respond_to?(:to_h)
98
+ new_attributes.to_h.stringify_keys
99
+ else
100
+ new_attributes.stringify_keys
101
+ end
102
+
103
+ attrs.each do |key, value|
104
+ if key.end_with?("_attributes")
105
+ association_name = key.delete_suffix("_attributes")
106
+ send("#{association_name}_attributes=", value)
107
+ elsif key == "_destroy"
108
+ self._destroy = value
109
+ else
110
+ public_send("#{key}=", value)
111
+ end
112
+ end
113
+
114
+ changes_applied
115
+ end
116
+
117
+ def from_model(model)
118
+ return self if model.nil?
119
+
120
+ self.class.attribute_names.each do |attr_name|
121
+ if model.respond_to?(attr_name)
122
+ public_send("#{attr_name}=", model.public_send(attr_name))
123
+ end
124
+ end
125
+
126
+ self.class.associations.each do |name, association|
127
+ next unless model.respond_to?(name)
128
+
129
+ associated_value = model.public_send(name)
130
+ next if associated_value.nil?
131
+
132
+ case association[:type]
133
+ when :has_one
134
+ form_instance = association[:class_name].constantize.new
135
+ form_instance.from_model(associated_value)
136
+ public_send("#{name}=", form_instance)
137
+ when :has_many
138
+ associated_value.each do |record|
139
+ form_instance = association[:class_name].constantize.new
140
+ form_instance.from_model(record)
141
+ public_send(name).push(form_instance)
142
+ end
143
+ end
144
+ end
145
+
146
+ self
147
+ end
148
+
149
+ def persisted?
150
+ respond_to?(:id) && id.present?
151
+ end
152
+
153
+ def to_key
154
+ persisted? ? [id] : nil
155
+ end
156
+
157
+ def to_param
158
+ persisted? ? id.to_s : nil
159
+ end
160
+
161
+ def to_model
162
+ self
163
+ end
164
+
165
+ def marked_for_destruction?
166
+ @marked_for_destruction
167
+ end
168
+
169
+ def mark_for_destruction
170
+ @marked_for_destruction = true
171
+ end
172
+
173
+ def _destroy
174
+ @marked_for_destruction
175
+ end
176
+
177
+ def _destroy=(value)
178
+ @marked_for_destruction = ActiveModel::Type::Boolean.new.cast(value)
179
+ end
180
+
181
+ def valid?(context = nil)
182
+ run_callbacks :validation do
183
+ super(context) && nested_forms_valid?
184
+ end
185
+ end
186
+
187
+ def model_attributes
188
+ attribute_names.each_with_object({}) do |name, hash|
189
+ hash[name] = public_send(name)
190
+ end
191
+ end
192
+
193
+ def attributes
194
+ attrs = model_attributes.dup
195
+
196
+ self.class.associations.each do |name, association|
197
+ nested_form = public_send(name)
198
+ next if nested_form.nil?
199
+
200
+ case association[:type]
201
+ when :has_one
202
+ nested_attrs = nested_form.attributes
203
+ nested_attrs["_destroy"] = true if nested_form.marked_for_destruction?
204
+ attrs["#{name}_attributes"] = nested_attrs
205
+ when :has_many
206
+ attrs["#{name}_attributes"] = nested_form.map do |form|
207
+ form_attrs = form.attributes
208
+ form_attrs["_destroy"] = true if form.marked_for_destruction?
209
+ form_attrs
210
+ end
211
+ end
212
+ end
213
+
214
+ attrs
215
+ end
216
+
217
+ def assignable_attributes(exclude: [:id])
218
+ excluded_keys = Array(exclude).map(&:to_s)
219
+ attributes.except(*excluded_keys)
220
+ end
221
+
222
+ private
223
+
224
+ def nested_forms_valid?
225
+ self.class.associations.all? do |name, association|
226
+ nested_form = public_send(name)
227
+ next true if nested_form.nil?
228
+
229
+ case association[:type]
230
+ when :has_one
231
+ if nested_form.valid?
232
+ true
233
+ else
234
+ copy_nested_errors(name, nested_form)
235
+ false
236
+ end
237
+ when :has_many
238
+ if nested_form.valid?
239
+ true
240
+ else
241
+ nested_form.each_with_index do |form, index|
242
+ copy_nested_errors("#{name}[#{index}]", form) unless form.valid?
243
+ end
244
+ false
245
+ end
246
+ end
247
+ end
248
+ end
249
+
250
+ def copy_nested_errors(association_name, nested_form)
251
+ nested_form.errors.each do |error|
252
+ errors.add("#{association_name}.#{error.attribute}", error.message)
253
+ end
254
+ end
255
+ end
256
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Blanks
4
+ module ModelNaming
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ class_attribute :_model_name_override
9
+ end
10
+
11
+ class_methods do
12
+ def model_name
13
+ return _model_name_override if _model_name_override
14
+
15
+ name_without_form = self.name.sub(/Form$/, '')
16
+ ActiveModel::Name.new(self, nil, name_without_form)
17
+ end
18
+
19
+ def model_name_for(name)
20
+ self._model_name_override = ActiveModel::Name.new(self, nil, name.to_s.camelize)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Blanks
4
+ module NestedAttributes
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ class_attribute :nested_attributes_options, default: {}
9
+ end
10
+
11
+ class_methods do
12
+ def accepts_nested_attributes_for(*attr_names)
13
+ options = attr_names.extract_options!
14
+
15
+ attr_names.each do |association_name|
16
+ self.nested_attributes_options = nested_attributes_options.merge(
17
+ association_name.to_s => options
18
+ )
19
+
20
+ define_nested_attributes_method(association_name)
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def define_nested_attributes_method(association_name)
27
+ define_method("#{association_name}_attributes=") do |attributes|
28
+ association = self.class.associations[association_name.to_s]
29
+
30
+ unless association
31
+ raise ArgumentError, "No association found for name '#{association_name}'"
32
+ end
33
+
34
+ case association[:type]
35
+ when :has_one
36
+ assign_nested_attributes_for_one_to_one_association(association_name, attributes)
37
+ when :has_many
38
+ assign_nested_attributes_for_collection_association(association_name, attributes)
39
+ end
40
+ end
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def assign_nested_attributes_for_one_to_one_association(association_name, attributes)
47
+ return if attributes.blank?
48
+
49
+ association = self.class.associations[association_name.to_s]
50
+ options = self.class.nested_attributes_options[association_name.to_s] || {}
51
+ form_class = association[:class_name].constantize
52
+ attrs_hash = attributes.stringify_keys
53
+
54
+ if has_destroy_flag?(attrs_hash) && options[:allow_destroy]
55
+ instance = public_send(association_name)
56
+ instance&.mark_for_destruction
57
+ return
58
+ end
59
+
60
+ instance = public_send(association_name)
61
+ attrs_without_destroy = attrs_hash.except("_destroy")
62
+ if instance
63
+ instance.assign_attributes(attrs_without_destroy)
64
+ else
65
+ instance = form_class.new(attrs_without_destroy)
66
+ public_send("#{association_name}=", instance)
67
+ end
68
+ end
69
+
70
+ def assign_nested_attributes_for_collection_association(association_name, attributes)
71
+ return if attributes.blank?
72
+
73
+ attributes_collection = attributes.is_a?(Hash) ? attributes.values : attributes
74
+ association = self.class.associations[association_name.to_s]
75
+ options = self.class.nested_attributes_options[association_name.to_s] || {}
76
+ form_class = association[:class_name].constantize
77
+ collection = public_send(association_name)
78
+ primary_key = (options[:primary_key] || association[:primary_key] || "id").to_s
79
+
80
+ attributes_collection.each do |attrs|
81
+ next if call_reject_if(association_name, attrs)
82
+
83
+ attrs_hash = attrs.is_a?(Hash) ? attrs.stringify_keys : attrs
84
+
85
+ if has_destroy_flag?(attrs_hash)
86
+ if options[:allow_destroy] && attrs_hash[primary_key].present?
87
+ existing = collection.find_by(primary_key, attrs_hash[primary_key])
88
+ if existing
89
+ existing.mark_for_destruction
90
+ else
91
+ new_form = collection.new(attrs_hash.except("_destroy"))
92
+ new_form.mark_for_destruction
93
+ end
94
+ end
95
+ next
96
+ end
97
+
98
+ if attrs_hash[primary_key].present?
99
+ existing = collection.find_by(primary_key, attrs_hash[primary_key])
100
+ if existing
101
+ existing.assign_attributes(attrs_hash.except(primary_key, "_destroy"))
102
+ else
103
+ collection.new(attrs_hash.except("_destroy"))
104
+ end
105
+ else
106
+ collection.new(attrs_hash.except("_destroy"))
107
+ end
108
+ end
109
+ end
110
+
111
+ def call_reject_if(association_name, attributes)
112
+ options = self.class.nested_attributes_options[association_name.to_s]
113
+ return false unless options
114
+
115
+ reject_if = options[:reject_if]
116
+ return false unless reject_if
117
+
118
+ if reject_if.is_a?(Symbol)
119
+ method(reject_if).call(attributes)
120
+ else
121
+ reject_if.call(attributes)
122
+ end
123
+ end
124
+
125
+ def has_destroy_flag?(attributes)
126
+ attrs = attributes.stringify_keys
127
+ value = attrs["_destroy"]
128
+ ActiveModel::Type::Boolean.new.cast(value)
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Blanks
4
+ module Normalization
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ class_attribute :_normalizations, instance_writer: false, default: {}
9
+ end
10
+
11
+ class_methods do
12
+ def normalizes(*names, with:, apply_to_nil: false)
13
+ names.each do |name|
14
+ _normalizations[name.to_s] = { normalizer: with, apply_to_nil: apply_to_nil }
15
+
16
+ define_method("#{name}=") do |value|
17
+ normalized_value = self.class.normalize_value_for(name, value)
18
+ super(normalized_value)
19
+ end
20
+ end
21
+ end
22
+
23
+ def normalize_value_for(name, value)
24
+ normalization = _normalizations[name.to_s]
25
+ return value unless normalization
26
+
27
+ return value if value.nil? && !normalization[:apply_to_nil]
28
+
29
+ normalizer = normalization[:normalizer]
30
+ if normalizer.respond_to?(:call)
31
+ normalizer.call(value)
32
+ else
33
+ value.public_send(normalizer)
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,3 @@
1
+ module Blanks
2
+ VERSION = "1.0.0"
3
+ end
data/lib/blanks.rb ADDED
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "active_support/core_ext/string/inflections"
5
+ require "active_support/core_ext/array/extract_options"
6
+ require "active_support/core_ext/object/blank"
7
+
8
+ require "blanks/version"
9
+ require "blanks/association_proxy"
10
+ require "blanks/model_naming"
11
+ require "blanks/normalization"
12
+ require "blanks/associations"
13
+ require "blanks/nested_attributes"
14
+ require "blanks/base"
15
+
16
+ module Blanks
17
+ class Error < StandardError; end
18
+ end