From 84921aa6f368fe9d4b4987894647aacbd3009d2b Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Wed, 12 Aug 2026 12:22:29 -0500 Subject: [PATCH 1/2] Read a has_one as an Option A has_one is absent whenever no row points back at the record, which is the same absence a nullable column carries, so a converted model that returned a bare nil from half its readers made the surface inconsistent: a caller had to know which kind of absence each reader spoke. required: true is left alone. It asserts the record is there, so absence is a validation failure rather than a value to handle. --- .../rails/active_record_optional.rb | 11 +++ test/rails_test.rb | 69 +++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/lib/errgonomic/rails/active_record_optional.rb b/lib/errgonomic/rails/active_record_optional.rb index 88b6e1a..4063b05 100644 --- a/lib/errgonomic/rails/active_record_optional.rb +++ b/lib/errgonomic/rails/active_record_optional.rb @@ -33,6 +33,9 @@ module ActiveRecordOptional reflect_on_all_associations(:belongs_to) .select { |r| r.options[:optional] } .each { |r| errgonomic_wrap_optional(r.name) } + reflect_on_all_associations(:has_one) + .reject { |r| r.options[:required] } + .each { |r| errgonomic_wrap_optional(r.name) } end class_methods do @@ -116,6 +119,14 @@ def belongs_to(name, scope = nil, **options) super.tap { errgonomic_wrap_optional(name) if options[:optional] } end + # A has_one is absent whenever no row points back at the record, so + # its reader carries the same absence a nullable column does. + # required: true is the exception: it asserts the record is there, and + # absence is a validation failure rather than a value to handle. + def has_one(name, scope = nil, **options) + super.tap { errgonomic_wrap_optional(name) unless options[:required] } + 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 fb9da94..b94722e 100644 --- a/test/rails_test.rb +++ b/test/rails_test.rb @@ -32,6 +32,18 @@ t.timestamps end + create_table 'profiles', force: :cascade do |t| + t.string :tagline + t.references :author + t.timestamps + end + + create_table 'awards', force: :cascade do |t| + t.string :name, null: false + t.references :author + t.timestamps + end + create_table 'magazines', force: :cascade do |t| t.string :title, null: false t.string :issn @@ -56,7 +68,25 @@ class Author < ActiveRecord::Base has_many :books + has_one :profile, dependent: :destroy + include Errgonomic::Rails::ActiveRecordOptional + has_one :award, dependent: :destroy +end + +class Profile < ActiveRecord::Base + belongs_to :author +end + +class Award < ActiveRecord::Base + belongs_to :author +end + +# A has_one declared required asserts the record is there, so its reader is +# left alone: absence is a validation failure rather than a value. +class Publisher < ActiveRecord::Base + self.table_name = 'authors' include Errgonomic::Rails::ActiveRecordOptional + has_one :profile, required: true, foreign_key: :author_id end class Book < ActiveRecord::Base @@ -319,6 +349,45 @@ def self.name = 'Quarterly' assert_equal %w[issn], quarterly.errgonomic_optionals end + # A has_one is absent whenever no row points back, so its reader carries + # the same absence a nullable column does, whichever side of the include + # it is declared on. + def test_has_one_reads_as_an_option + author = Author.create!(name: 'Cixin Liu') + + assert author.profile.none? + assert author.award.none? + + author.create_profile!(tagline: 'writes sci-fi') + author.create_award!(name: 'Hugo') + + assert_equal 'writes sci-fi', author.reload.profile.unwrap!.tagline + assert_equal 'Hugo', author.award.unwrap!.name + end + + # Absence is representable, so the association's own machinery has to keep + # working through the wrapper. + def test_has_one_writes_and_dependent_destroy_still_work + author = Author.create!(name: 'Cixin Liu') + author.profile = Profile.new(tagline: 'writes sci-fi') + author.save! + + assert_equal 'writes sci-fi', author.reload.profile.unwrap!.tagline + + author.destroy! + + assert_equal 0, Profile.where(author_id: author.id).count + 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 + publisher = Publisher.create!(name: 'Tor', profile: Profile.new(tagline: 'imprint')) + + assert_equal 'imprint', publisher.reload.profile.tagline + assert_raises(ActiveRecord::RecordInvalid) { Publisher.create!(name: 'Baen') } + end + # An inherited reader is already wrapped, and a second wrap nests: Some of # a Some for a present value, while an absent one collapses back to None # because None answers nil?. Half of it is silent. From 03c58d958d9ad4bfa94c4f0db3be0369f7dcde12 Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Wed, 12 Aug 2026 12:24:37 -0500 Subject: [PATCH 2/2] 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