voids 2.0.0 → 2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d9b79995cb20862d8567f3031c8bf0a8a12dd9501ce8be84dbdf31472f3e2aac
4
- data.tar.gz: 3150180efa5c939ab91e70fef7dc9c5b489d9155c504908629a1821ae454ccfc
3
+ metadata.gz: 2ddc0ec3f6eb944d0b91f7d661887b9ef36c7b6e02aa63606cf8eafff8a6c71a
4
+ data.tar.gz: 8fe0046ebed0bf511b7ce893a8e2cc48269233f7c617469c6e80e262d953d249
5
5
  SHA512:
6
- metadata.gz: 7773cc74efa588e6d919e225e4338b2c79005f5153932f63b2cc2965857ce73ffebef0ed33dae093c1bfb3a9c92291e95459f4802a37a56c3049212025706356
7
- data.tar.gz: 2c7d591765982c89822c1c968c6e15d6a9a7b10f2dda51035a42b014f0bb77fdf79056c4a0f96ac7c69774a0ff5961a070d183a044c9d37c5d39c169a0710c00
6
+ metadata.gz: 522dd880a0decbaadc5f8584e84ba822cf00f777653c829f618a7626a851a0088125c45c4ec25f382e56b7bf588a114f1a7edf5978794586bebfcb8e5a076a9c
7
+ data.tar.gz: 8135b1214dc06dae3f6e2e691a250a30a4708de8b661cc1b82ad96f05020ce5b10cfc1c6586e6044dd12fa0fdb94c823ef6851df4ee20acec95e7f9916fa11be
data/.circleci/config.yml CHANGED
@@ -4,7 +4,7 @@ orbs:
4
4
  workflows:
5
5
  build:
6
6
  jobs:
7
- - ruby-rails/lint:
7
+ - ruby-rails/lint-gem:
8
8
  name: lint
9
9
  - ruby-rails/test-gem:
10
10
  name: test
data/.rubocop_todo.yml CHANGED
@@ -1,6 +1,6 @@
1
1
  # This configuration was generated by
2
2
  # `rubocop --auto-gen-config --auto-gen-only-exclude`
3
- # on 2026-09-14 20:55:19 UTC using RuboCop version 1.91.0.
3
+ # on 2026-09-14 23:45:19 UTC using RuboCop version 1.91.0.
4
4
  # The point is for the user to remove these configuration records
5
5
  # one by one as the offenses are removed from the code base.
6
6
  # Note that changes in the inspected code, or installation of new
@@ -19,10 +19,11 @@ Lint/SelfAssignment:
19
19
  Exclude:
20
20
  - 'spec/voids/normalization_spec.rb'
21
21
 
22
- # Offense count: 7
22
+ # Offense count: 8
23
23
  # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes, Max.
24
24
  Metrics/AbcSize:
25
25
  Exclude:
26
+ - 'lib/voids/associations.rb'
26
27
  - 'lib/voids/base.rb'
27
28
  - 'lib/voids/nested_attributes.rb'
28
29
 
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  ## [Unreleased]
2
2
 
3
+ - Add `changes_applied:` option to `Voids::Base#assign_attributes` so callers can bulk-assign attributes without resetting dirty tracking.
4
+ - Propagate validation context to nested forms during parent validation.
5
+ - Validate nested forms even when parent validations fail, so nested errors still bubble up.
6
+ - Add `Voids::Base#empty?` with default emptiness semantics based on blank attributes.
7
+ - Make `AssociationProxy#any?` block-aware (matching Ruby collection semantics).
8
+ - Add `AssociationProxy#reverse` for array-like read access without explicit `to_a` conversion.
9
+
3
10
  ## [1.0.0] - 2026-01-13
4
11
 
5
12
  - Initial release
data/README.md CHANGED
@@ -1,3 +1,7 @@
1
+ [![Gem version](https://badge.fury.io/rb/voids.svg)](https://badge.fury.io/rb/voids)
2
+ [![CircleCI](https://dl.circleci.com/status-badge/img/gh/sul-dlss/voids/tree/main.svg?style=svg)](https://dl.circleci.com/status-badge/redirect/gh/sul-dlss/voids/tree/main)
3
+ [![Codecov](https://codecov.io/github/sul-dlss/voids/graph/badge.svg?token=5PZ9TARO34)](https://codecov.io/github/sul-dlss/voids)
4
+
1
5
  # Voids
2
6
 
3
7
  _fill it in_.
@@ -36,6 +40,9 @@ form = PostForm.new(title: "hello", content: "world")
36
40
  form.valid? # true
37
41
  ```
38
42
 
43
+ `Voids::Base` includes a default `empty?` implementation: a form is empty when all `attributes` values are blank.
44
+ Override this in subclasses if your form uses custom emptiness semantics.
45
+
39
46
  ### Inheriting attributes from models
40
47
 
41
48
  Pull attribute definitions from existing models instead of manually defining each one.
@@ -129,6 +136,20 @@ form.images.new(url: "https://example.com/image.jpg")
129
136
  form.images.count # 1
130
137
  ```
131
138
 
139
+ `has_many` associations return a collection proxy that supports common collection methods:
140
+
141
+ ```ruby
142
+ form.images.any? # true/false
143
+ form.images.any? { |image| image.url.present? } # true/false
144
+ form.images.reverse # array in reverse order
145
+ ```
146
+
147
+ Notes:
148
+
149
+ 1. `any?` supports both no-block and block forms.
150
+ 2. `reverse` returns a reversed array view and does not mutate the underlying proxy order.
151
+ 3. `to_a` is still available when explicit array conversion is desired.
152
+
132
153
  ### Nested attributes from params
133
154
 
134
155
  ```ruby
@@ -168,13 +189,14 @@ form.from_model(post)
168
189
 
169
190
  ### Validation with nested forms
170
191
 
171
- Validations automatically cascade to nested forms:
192
+ Validations automatically cascade to nested forms and use the same validation context:
172
193
 
173
194
  ```ruby
174
195
  form = PostForm.new(title: "hello")
175
196
  form.images.new(url: nil) # invalid image
176
197
 
177
198
  form.valid? # false
199
+ form.valid?(:publish) # false
178
200
  form.errors.full_messages # includes nested form errors
179
201
  ```
180
202
 
@@ -285,6 +307,24 @@ form.title_was # "original"
285
307
  form.changes # { "title" => ["original", "changed"] }
286
308
  ```
287
309
 
310
+ `assign_attributes` resets dirty tracking when it finishes, so the form reports
311
+ itself as clean and the assignments move to `previous_changes`. Pass
312
+ `changes_applied: false` to keep dirty tracking intact, which is useful when the
313
+ caller needs to know what a params hash actually changed:
314
+
315
+ ```ruby
316
+ form = PostForm.new(title: "original")
317
+ form.assign_attributes(params[:post], changes_applied: false)
318
+
319
+ form.changed? # true
320
+ form.changes # { "title" => ["original", "submitted"] }
321
+ ```
322
+
323
+ Attributes can be passed positionally or as keywords; `:changes_applied` is
324
+ never treated as an attribute. Nested forms assigned through `*_attributes=`
325
+ writers still reset their own dirty tracking, since those writers follow the
326
+ Rails setter convention and take no options.
327
+
288
328
  ### Normalization
289
329
 
290
330
  Normalize attribute values on assignment. Works on Rails 6+, not just 7.1+.
@@ -55,8 +55,14 @@ module Voids
55
55
  @records.empty?
56
56
  end
57
57
 
58
- def any?
59
- @records.any?
58
+ def any?(&block)
59
+ return @records.any? unless block
60
+
61
+ @records.any?(&block)
62
+ end
63
+
64
+ def reverse
65
+ @records.reverse
60
66
  end
61
67
 
62
68
  def [](index)
@@ -68,6 +74,12 @@ module Voids
68
74
  @records_by_id.clear
69
75
  end
70
76
 
77
+ def replace(records)
78
+ clear
79
+ Array(records).each { |record| push(record) }
80
+ self
81
+ end
82
+
71
83
  def to_a
72
84
  @records
73
85
  end
@@ -80,8 +92,8 @@ module Voids
80
92
  false
81
93
  end
82
94
 
83
- def valid?
84
- @records.all?(&:valid?)
95
+ def valid?(context = nil)
96
+ @records.all? { it.valid?(context) }
85
97
  end
86
98
 
87
99
  def errors
@@ -49,7 +49,7 @@ module Voids
49
49
  end
50
50
 
51
51
  define_method("#{name}=") do |value|
52
- instance_variable_set("@#{name}", value)
52
+ public_send(name).replace(value)
53
53
  end
54
54
 
55
55
  accepts_nested_attributes_for(name, primary_key: primary_key, **)
data/lib/voids/base.rb CHANGED
@@ -87,7 +87,20 @@ module Voids
87
87
  assign_attributes(attributes) if attributes.present?
88
88
  end
89
89
 
90
- def assign_attributes(new_attributes)
90
+ # Assigns the given attributes. Afterwards the form is marked as clean
91
+ # (dirty tracking is reset) unless `changes_applied: false` is given.
92
+ #
93
+ # Attributes may be passed positionally (`assign_attributes(params)`) or as
94
+ # keywords (`assign_attributes(title: 'new')`), so `:changes_applied` is
95
+ # extracted from the keywords rather than declared as a keyword argument.
96
+ def assign_attributes(new_attributes = nil, **options)
97
+ changes_applied = options.fetch(:changes_applied, true)
98
+ keyword_attributes = options.except(:changes_applied)
99
+ if new_attributes && keyword_attributes.any?
100
+ raise ArgumentError, 'pass attributes either positionally or as keywords, not both'
101
+ end
102
+
103
+ new_attributes ||= keyword_attributes
91
104
  return if new_attributes.blank?
92
105
 
93
106
  attrs = if new_attributes.respond_to?(:to_unsafe_h)
@@ -109,7 +122,7 @@ module Voids
109
122
  end
110
123
  end
111
124
 
112
- changes_applied
125
+ self.changes_applied if changes_applied
113
126
  end
114
127
 
115
128
  def from_model(model)
@@ -176,7 +189,9 @@ module Voids
176
189
 
177
190
  def valid?(context = nil)
178
191
  run_callbacks :validation do
179
- super(context) && nested_forms_valid?
192
+ parent_valid = super(context)
193
+ nested_valid = nested_forms_valid?(context)
194
+ parent_valid && nested_valid
180
195
  end
181
196
  end
182
197
 
@@ -215,32 +230,38 @@ module Voids
215
230
  attributes.except(*excluded_keys)
216
231
  end
217
232
 
233
+ def empty?
234
+ attributes.all? { |_name, value| value.blank? }
235
+ end
236
+
218
237
  private
219
238
 
220
- def nested_forms_valid?
221
- self.class.associations.all? do |name, association|
239
+ def nested_forms_valid?(context)
240
+ all_valid = true
241
+
242
+ self.class.associations.each do |name, association|
222
243
  nested_form = public_send(name)
223
- next true if nested_form.nil?
244
+ next if nested_form.nil?
224
245
 
225
246
  case association[:type]
226
247
  when :has_one
227
- if nested_form.valid?
228
- true
229
- else
230
- copy_nested_errors(name, nested_form)
231
- false
232
- end
248
+ next if nested_form.valid?(context)
249
+
250
+ errors.add(name.to_sym, :invalid)
251
+ copy_nested_errors(name, nested_form)
252
+ all_valid = false
233
253
  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
254
+ next if nested_form.valid?(context)
255
+
256
+ errors.add(name.to_sym, :invalid)
257
+ nested_form.each_with_index do |form, index|
258
+ copy_nested_errors("#{name}[#{index}]", form) unless form.valid?(context)
241
259
  end
260
+ all_valid = false
242
261
  end
243
262
  end
263
+
264
+ all_valid
244
265
  end
245
266
 
246
267
  def copy_nested_errors(association_name, nested_form)
data/lib/voids/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Voids
4
- VERSION = '2.0.0'
4
+ VERSION = '2.1.0'
5
5
  end
@@ -148,6 +148,14 @@ RSpec.describe Voids::AssociationProxy do
148
148
 
149
149
  expect(proxy.any?).to be(true)
150
150
  end
151
+
152
+ it 'supports block form' do
153
+ proxy = described_class.new('ItemForm')
154
+ proxy.new(url: 'first')
155
+ proxy.new(url: nil)
156
+
157
+ expect(proxy.any? { |item| item.url.nil? }).to be(true)
158
+ end
151
159
  end
152
160
 
153
161
  describe '#[]' do
@@ -172,6 +180,29 @@ RSpec.describe Voids::AssociationProxy do
172
180
  end
173
181
  end
174
182
 
183
+ describe '#replace' do
184
+ it 'replaces records and returns self' do
185
+ proxy = described_class.new('ItemForm')
186
+ original = proxy.new(url: 'first')
187
+ replacement = ItemForm.new(url: 'second')
188
+
189
+ result = proxy.replace([replacement])
190
+
191
+ expect(result).to eq(proxy)
192
+ expect(proxy.to_a).to eq([replacement])
193
+ expect(proxy.to_a).not_to include(original)
194
+ end
195
+
196
+ it 'clears records when passed nil' do
197
+ proxy = described_class.new('ItemForm')
198
+ proxy.new(url: 'first')
199
+
200
+ proxy.replace(nil)
201
+
202
+ expect(proxy).to be_empty
203
+ end
204
+ end
205
+
175
206
  describe '#to_a' do
176
207
  it 'returns the underlying array' do
177
208
  proxy = described_class.new('ItemForm')
@@ -182,32 +213,51 @@ RSpec.describe Voids::AssociationProxy do
182
213
  end
183
214
  end
184
215
 
216
+ describe '#reverse' do
217
+ it 'returns records in reverse order' do
218
+ proxy = described_class.new('ItemForm')
219
+ first = proxy.new(url: 'first')
220
+ second = proxy.new(url: 'second')
221
+
222
+ expect(proxy.reverse).to eq([second, first])
223
+ # Keep default forward iteration behavior unchanged.
224
+ expect(proxy.to_a).to eq([first, second])
225
+ end
226
+ end
227
+
185
228
  describe '#valid?' do
186
- it 'returns true when all items are valid' do
187
- valid_form_class = Class.new(Voids::Base) do
229
+ subject(:proxy) { described_class.new('ValidForm') }
230
+
231
+ let(:valid_form_class) do
232
+ Class.new(Voids::Base) do
188
233
  attribute :url, :string
189
234
  validates :url, presence: true
235
+ validates :url, format: { with: /\Ahttps:.+/, message: 'must be HTTPS' }, on: :final_setup
190
236
  end
237
+ end
238
+
239
+ before do
191
240
  stub_const('ValidForm', valid_form_class)
192
241
 
193
- proxy = described_class.new('ValidForm')
194
242
  proxy.new(url: 'https://example.com')
243
+ end
195
244
 
196
- expect(proxy.valid?).to be(true)
245
+ context 'when all items are valid' do
246
+ it { is_expected.to be_valid }
197
247
  end
198
248
 
199
- it 'returns false when any item is invalid' do
200
- valid_form_class = Class.new(Voids::Base) do
201
- attribute :url, :string
202
- validates :url, presence: true
249
+ context 'when validation context is set' do
250
+ before { proxy.new(url: 'http://example.com') }
251
+
252
+ it 'returns false' do
253
+ expect(proxy.valid?(:final_setup)).to be false
203
254
  end
204
- stub_const('ValidForm', valid_form_class)
255
+ end
205
256
 
206
- proxy = described_class.new('ValidForm')
207
- proxy.new(url: 'https://example.com')
208
- proxy.new(url: nil)
257
+ context 'when any item is invalid' do
258
+ before { proxy.new(url: nil) }
209
259
 
210
- expect(proxy.valid?).to be(false)
260
+ it { is_expected.not_to be_valid }
211
261
  end
212
262
  end
213
263
  end
@@ -181,5 +181,27 @@ RSpec.describe Voids::Associations do
181
181
 
182
182
  expect(item).to be_a(CustomForm)
183
183
  end
184
+
185
+ it 'replaces records in existing association proxy when assigned an array' do
186
+ nested_class = Class.new(Voids::Base) do
187
+ attribute :url, :string
188
+ end
189
+ stub_const('ImageForm', nested_class)
190
+
191
+ form_class = Class.new(Voids::Base) do
192
+ has_many :images
193
+ end
194
+
195
+ original = ImageForm.new(url: 'https://example.com/old')
196
+ replacement = ImageForm.new(url: 'https://example.com/new')
197
+ form = form_class.new
198
+ proxy = form.images
199
+ proxy << original
200
+
201
+ form.images = [replacement]
202
+
203
+ expect(form.images).to be(proxy)
204
+ expect(form.images.to_a).to eq([replacement])
205
+ end
184
206
  end
185
207
  end
@@ -85,6 +85,29 @@ RSpec.describe Voids::Base do
85
85
 
86
86
  expect(form.photo.url).to eq('https://example.com')
87
87
  end
88
+
89
+ it 'assigns keyword attributes alongside changes_applied' do
90
+ form_class = Class.new(described_class) do
91
+ attribute :title, :string
92
+ end
93
+
94
+ form = form_class.new
95
+ form.assign_attributes(title: 'new title', changes_applied: false)
96
+
97
+ expect(form.title).to eq('new title')
98
+ end
99
+
100
+ it 'raises when attributes are passed both positionally and as keywords' do
101
+ form_class = Class.new(described_class) do
102
+ attribute :title, :string
103
+ attribute :content, :string
104
+ end
105
+
106
+ form = form_class.new
107
+
108
+ expect { form.assign_attributes({ title: 'new title' }, content: 'new content') }
109
+ .to raise_error(ArgumentError, 'pass attributes either positionally or as keywords, not both')
110
+ end
88
111
  end
89
112
 
90
113
  describe '.from_model' do
@@ -280,6 +303,30 @@ RSpec.describe Voids::Base do
280
303
  end
281
304
  end
282
305
 
306
+ describe '#empty?' do
307
+ it 'returns true when all attributes are blank' do
308
+ form_class = Class.new(described_class) do
309
+ attribute :title, :string
310
+ attribute :count, :integer
311
+ end
312
+
313
+ form = form_class.new
314
+
315
+ expect(form.empty?).to be(true)
316
+ end
317
+
318
+ it 'returns false when any attribute is present' do
319
+ form_class = Class.new(described_class) do
320
+ attribute :title, :string
321
+ attribute :count, :integer
322
+ end
323
+
324
+ form = form_class.new(title: 'present')
325
+
326
+ expect(form.empty?).to be(false)
327
+ end
328
+ end
329
+
283
330
  describe '#valid?' do
284
331
  it 'returns true when all validations pass' do
285
332
  form_class = Class.new(described_class) do
@@ -314,6 +361,7 @@ RSpec.describe Voids::Base do
314
361
  has_one :photo
315
362
  attribute :title, :string
316
363
  end
364
+ stub_const('PhotoWrapperForm', form_class)
317
365
 
318
366
  form = form_class.new(title: 'test')
319
367
  form.build_photo
@@ -332,11 +380,14 @@ RSpec.describe Voids::Base do
332
380
  has_many :images
333
381
  attribute :title, :string
334
382
  end
383
+ stub_const('ImageGalleryForm', form_class)
335
384
 
336
385
  form = form_class.new(title: 'test')
337
386
  form.images.new
338
387
 
339
388
  expect(form.valid?).to be(false)
389
+ expect(form.errors.details[:images]).to include(error: :invalid)
390
+ expect(form.errors.messages.keys).to include(:'images[0].url')
340
391
  end
341
392
 
342
393
  it 'includes nested form errors in parent errors' do
@@ -350,12 +401,59 @@ RSpec.describe Voids::Base do
350
401
  has_one :photo
351
402
  attribute :title, :string
352
403
  end
404
+ stub_const('PhotoWrapperForm', form_class)
353
405
 
354
406
  form = form_class.new(title: 'test')
355
407
  form.build_photo
356
408
  form.valid?
357
409
 
358
- expect(form.errors.messages.keys).to include(:'photo.url')
410
+ expect(form.errors.messages.keys).to include(:photo, :'photo.url')
411
+ expect(form.errors.details[:photo]).to include(error: :invalid)
412
+ end
413
+
414
+ it 'includes nested form errors in parent even when parent has errors' do
415
+ nested_class = Class.new(described_class) do
416
+ attribute :url, :string
417
+ validates :url, presence: true
418
+ end
419
+ stub_const('PhotoForm', nested_class)
420
+
421
+ form_class = Class.new(described_class) do
422
+ has_one :photo
423
+ attribute :title, :string
424
+ validates :title, presence: true
425
+ end
426
+ stub_const('ImageForm', form_class)
427
+
428
+ form = form_class.new
429
+ form.build_photo
430
+ form.valid?
431
+
432
+ expect(form.errors.messages.keys).to contain_exactly(:title, :photo, :'photo.url')
433
+ expect(form.errors.details[:photo]).to include(error: :invalid)
434
+ end
435
+
436
+ it 'passes the validation context to nested forms' do
437
+ nested_class = Class.new(described_class) do
438
+ attribute :url, :string
439
+ validates :url, presence: true
440
+ validates :url, format: { with: /\Ahttps:.+/, message: 'must be HTTPS' }, on: :final_setup
441
+ end
442
+ stub_const('PhotoForm', nested_class)
443
+
444
+ form_class = Class.new(described_class) do
445
+ has_one :photo
446
+ attribute :title, :string
447
+ validates :title, presence: true
448
+ end
449
+ stub_const('ImageForm', form_class)
450
+
451
+ form = form_class.new(title: 'Summer in Sicily')
452
+ form.build_photo(url: 'http://myspace.com/sicily-photos')
453
+ form.valid?(:final_setup)
454
+
455
+ expect(form.errors.messages.keys).to contain_exactly(:photo, :'photo.url')
456
+ expect(form.errors.details[:photo]).to include(error: :invalid)
359
457
  end
360
458
  end
361
459
  end
@@ -49,6 +49,42 @@ RSpec.describe 'Dirty tracking' do
49
49
  expect(form.changed?).to be(false)
50
50
  end
51
51
 
52
+ it 'clears changes after assign_attributes when changes_applied is true' do
53
+ form_class = Class.new(Voids::Base) do
54
+ attribute :title, :string
55
+ end
56
+
57
+ form = form_class.new(title: 'original')
58
+ form.title = 'changed'
59
+ form.assign_attributes({ title: 'new' }, changes_applied: true)
60
+
61
+ expect(form.changed?).to be(false)
62
+ end
63
+
64
+ it 'preserves earlier changes when changes_applied is false' do
65
+ form_class = Class.new(Voids::Base) do
66
+ attribute :title, :string
67
+ attribute :content, :string
68
+ end
69
+
70
+ form = form_class.new(title: 'original', content: 'original content')
71
+ form.title = 'changed'
72
+ form.assign_attributes({ content: 'new content' }, changes_applied: false)
73
+
74
+ expect(form.title_was).to eq('original')
75
+ end
76
+
77
+ it 'records the assignment as a change when changes_applied is false' do
78
+ form_class = Class.new(Voids::Base) do
79
+ attribute :title, :string
80
+ end
81
+
82
+ form = form_class.new(title: 'original')
83
+ form.assign_attributes({ title: 'new' }, changes_applied: false)
84
+
85
+ expect(form.changes).to eq('title' => %w[original new])
86
+ end
87
+
52
88
  it 'detects if any attributes changed' do
53
89
  form_class = Class.new(Voids::Base) do
54
90
  attribute :title, :string
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: voids
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Josh Brody