From 03c58d958d9ad4bfa94c4f0db3be0369f7dcde12 Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Wed, 12 Aug 2026 12:24:37 -0500 Subject: [PATCH] Leave a singular association with nested attributes unwrapped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit accepts_nested_attributes_for assigns through the public reader and asks whatever it finds there whether it is a new record. A None answers nil? but is not nil, so it reached that question and raised, taking out build, update and destroy for the association — on an optional belongs_to as much as on a has_one. ActiveRecord needs a bare nil here, the way it needs the raw value behind encrypts, so this takes the same shape: the association is recorded as an exclusion and keeps its plain reader. --- README.md | 4 +- .../rails/active_record_optional.rb | 31 ++++++++++-- test/rails_test.rb | 50 +++++++++++++++++++ 3 files changed, 80 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0924096..cf895f6 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,7 @@ end When `Rails::Railtie` is defined, Errgonomic installs a Railtie with two opt-in integrations for ActiveRecord: -- `include Errgonomic::Rails::ActiveRecordOptional` in a model makes its nullable attributes and `optional: true` associations return `Some(value)` or `None()` instead of a value-or-nil. Every nullable column and optional association is wrapped, with no per-attribute opt-in. Two kinds of attribute stay unwrapped: those declared with `encrypts`, whose surrounding machinery reads the raw value, and those named by `errgonomic_optional_except`. +- `include Errgonomic::Rails::ActiveRecordOptional` in a model makes its nullable attributes and `optional: true` associations return `Some(value)` or `None()` instead of a value-or-nil. Every nullable column and optional association is wrapped, with no per-attribute opt-in. Three kinds of reader stay unwrapped: attributes declared with `encrypts` and singular associations with `accepts_nested_attributes_for`, both of which ActiveRecord's own machinery reads raw, and anything named by `errgonomic_optional_except`. ```ruby class Credential < ApplicationRecord @@ -209,6 +209,8 @@ class Credential < ApplicationRecord include Errgonomic::Rails::ActiveRecordOptional encrypts :access_secret # also left unwrapped, declared either side of the include + has_one :rotation_schedule # wrapped: Some(schedule) or None() + has_one :owner, required: true # left unwrapped: absence is a validation failure end ``` diff --git a/lib/errgonomic/rails/active_record_optional.rb b/lib/errgonomic/rails/active_record_optional.rb index 4063b05..d70d80f 100644 --- a/lib/errgonomic/rails/active_record_optional.rb +++ b/lib/errgonomic/rails/active_record_optional.rb @@ -19,9 +19,11 @@ module Rails # boundary, so an Option can be passed to where/quote. # 4. SomeValidator provides a presence-style validation for Option # attributes. - # 5. Attributes declared with encrypts are never wrapped: ActiveRecord - # Encryption registers a length validator outside Model.validators - # that reads the raw value and cannot survive an Option. + # 5. Readers that ActiveRecord's own machinery reads raw are never + # wrapped: an attribute declared with encrypts, whose length validator + # sits outside Model.validators and calls to_s on the value, and a + # singular association with nested attributes, which are assigned + # through the reader and ask the value whether it is a new record. # # errgonomic_optional_except is not on the list: it is configuration, an # escape hatch for whatever conflict shows up next, not a semantic @@ -64,7 +66,10 @@ def errgonomic_optional_exclusions [] end - inherited | Array(encrypted_attributes).map(&:to_s) | Array(try(:errgonomic_optional_exceptions)).map(&:to_s) + inherited | + Array(encrypted_attributes).map(&:to_s) | + Array(try(:errgonomic_optional_exceptions)).map(&:to_s) | + errgonomic_nested_attribute_associations end # A model that keeps value-or-nil throughout, for whatever the @@ -127,6 +132,24 @@ def has_one(name, scope = nil, **options) super.tap { errgonomic_wrap_optional(name) unless options[:required] } end + # Nested attributes are assigned through the public reader, and + # ActiveRecord asks whatever it finds there whether it is a new + # record. An absent association has to arrive as nil for that, so a + # singular association with nested attributes keeps its plain reader. + def accepts_nested_attributes_for(*names, **options) + super.tap { errgonomic_unwrap_optionals(*names) } + end + + # ActiveRecord keeps its own register of these, so the exclusion can be + # read from there rather than recorded as it goes past. + def errgonomic_nested_attribute_associations + return [] unless respond_to?(:nested_attributes_options) + + nested_attributes_options.keys.map(&:to_s).select do |name| + %i[has_one belongs_to].include?(reflect_on_association(name)&.macro) + end + end + # Encryption surrounds an attribute with machinery that reads the raw # value, including a length validator that calls to_s on it, so a # wrapped encrypted attribute cannot be saved. Declaring encrypts diff --git a/test/rails_test.rb b/test/rails_test.rb index b94722e..4d7e334 100644 --- a/test/rails_test.rb +++ b/test/rails_test.rb @@ -127,6 +127,23 @@ class Credential < ActiveRecord::Base encrypts :access_secret end +# Nested attributes are assigned through the public reader, and ActiveRecord +# asks whatever it finds there whether it is a new record, so a wrapped +# singular association cannot survive the round trip. +class Editor < ActiveRecord::Base + self.table_name = 'authors' + include Errgonomic::Rails::ActiveRecordOptional + has_one :profile, foreign_key: :author_id + accepts_nested_attributes_for :profile, allow_destroy: true +end + +class Anthology < ActiveRecord::Base + self.table_name = 'books' + include Errgonomic::Rails::ActiveRecordOptional + belongs_to :author, optional: true + accepts_nested_attributes_for :author +end + # An opt-out named before the include keeps an attribute unwrapped, for # machinery the concern does not know about. class OptedOutCredential < ActiveRecord::Base @@ -379,6 +396,39 @@ def test_has_one_writes_and_dependent_destroy_still_work assert_equal 0, Profile.where(author_id: author.id).count end + # ActiveRecord reads the association, asks it whether it is a new record, + # and assigns through it, so the reader has to stay plain for the whole + # nested-attributes cycle: build, update, and destroy. + def test_nested_attributes_on_a_has_one_keep_working + editor = Editor.create!(name: 'Cixin Liu') + + editor.update!(profile_attributes: { tagline: 'writes sci-fi' }) + + assert_equal 'writes sci-fi', editor.reload.profile.tagline + + editor.update!(profile_attributes: { id: editor.profile.id, tagline: 'revised' }) + + assert_equal 'revised', editor.reload.profile.tagline + + editor.update!(profile_attributes: { id: editor.profile.id, _destroy: '1' }) + + assert_nil editor.reload.profile + end + + def test_nested_attributes_on_an_optional_belongs_to_keep_working + anthology = Anthology.create!(title: 'Wandering Earth', author_attributes: { name: 'Cixin Liu' }) + + assert_equal 'Cixin Liu', anthology.reload.author.name + end + + # The unwrapped set is discoverable, so a converted model can say which + # readers ActiveRecord kept for itself. + def test_an_association_with_nested_attributes_is_reported_as_unwrapped + refute_includes Editor.errgonomic_optionals, 'profile' + refute_includes Anthology.errgonomic_optionals, 'author' + assert_includes Anthology.errgonomic_optional_exclusions, 'author' + end + # required: true says the record is always there, which is a validation, # not an absence to represent. def test_a_required_has_one_is_left_unwrapped