From 0bd9f4e60c8bc3c4bcd0438334aeb3bce5bb720b Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Wed, 16 Sep 2026 11:57:11 -0500 Subject: [PATCH 01/10] Reproduce #88's claim that a wrapped column's attr? answers true for false and 0 Each case names what an unconverted model answers as the expected value, and query_attribute and its private attribute? alias are read separately because the generated readers dispatch through the alias. --- test/reproductions/issue_88_test.rb | 75 +++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 test/reproductions/issue_88_test.rb diff --git a/test/reproductions/issue_88_test.rb b/test/reproductions/issue_88_test.rb new file mode 100644 index 0000000..b7835d5 --- /dev/null +++ b/test/reproductions/issue_88_test.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +# The Rails integration test owns the connection, the schema and the engine +# wiring for this process; the reproduction adds a table of its own to it. +require_relative '../rails_test' + +ActiveRecord::Schema.define do + create_table 'staff_records', force: :cascade do |t| + t.boolean :staff_access + t.integer :credits + t.string :handle + end +end + +class PlainStaffRecord < ActiveRecord::Base + self.table_name = 'staff_records' +end + +class WrappedStaffRecord < ActiveRecord::Base + self.table_name = 'staff_records' + include Errgonomic::Rails::ActiveRecordOptional +end + +# Issue #88 claims the Rails-generated `attr?` query method answers true for a +# wrapped `Some(false)` and a wrapped `Some(0)`, because ActiveRecord's +# `query_attribute` reads the wrapped reader and falls through to +# `!value.blank?`, and an Option's blankness is its discriminant. The expected +# value in each case is what an unconverted model answers. +class Issue88QueryAttributeTest < Minitest::Test + def test_plain_active_record_answers_false_for_an_explicit_false + refute_predicate PlainStaffRecord.new(staff_access: false), :staff_access? + end + + def test_a_wrapped_nullable_boolean_false_answers_false + refute_predicate WrappedStaffRecord.new(staff_access: false), :staff_access? + end + + def test_plain_active_record_answers_false_for_a_stored_zero + refute_predicate PlainStaffRecord.new(credits: 0), :credits? + end + + def test_a_wrapped_nullable_integer_zero_answers_false + refute_predicate WrappedStaffRecord.new(credits: 0), :credits? + end + + def test_plain_active_record_answers_false_for_an_empty_string + refute_predicate PlainStaffRecord.new(handle: ''), :handle? + end + + def test_a_wrapped_empty_string_answers_false + refute_predicate WrappedStaffRecord.new(handle: ''), :handle? + end + + def test_plain_active_record_answers_false_for_nil + refute_predicate PlainStaffRecord.new, :staff_access? + end + + def test_a_wrapped_nil_answers_false + refute_predicate WrappedStaffRecord.new, :staff_access? + end + + def test_a_wrapped_true_still_answers_true + assert_predicate WrappedStaffRecord.new(staff_access: true), :staff_access? + end + + # The generated `attr?` methods dispatch through the private `attribute?` + # alias rather than `query_attribute`, so both are read here. + def test_the_private_query_attribute_reader_answers_false + refute WrappedStaffRecord.new(staff_access: false).send(:query_attribute, 'staff_access') + end + + def test_the_private_attribute_query_alias_answers_false + refute WrappedStaffRecord.new(staff_access: false).send(:attribute?, 'staff_access') + end +end From 2a6a9b467af9d989e6751f61eb73be250f52a5c4 Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Wed, 16 Sep 2026 12:07:45 -0500 Subject: [PATCH 02/10] Assert a query method answers what the unconverted model answers The reproduction stood up a table and two models of its own; the Rails integration test already has a converted model and its unconverted twin on the same row, which is what every case here compares against. --- test/rails_test.rb | 55 +++++++++++++++++++++ test/reproductions/issue_88_test.rb | 75 ----------------------------- 2 files changed, 55 insertions(+), 75 deletions(-) delete mode 100644 test/reproductions/issue_88_test.rb diff --git a/test/rails_test.rb b/test/rails_test.rb index 32dca4c..5ed14b4 100644 --- a/test/rails_test.rb +++ b/test/rails_test.rb @@ -549,6 +549,14 @@ class PlainNote < ActiveRecord::Base self.table_name = 'notes' end +# One column left unwrapped, so the seams a wrapped reader passes through can +# be asked what they answer for a plain value on a converted model. +class PartlyPlainNote < ActiveRecord::Base + self.table_name = 'notes' + errgonomic_optional_except :score + include Errgonomic::Rails::ActiveRecordOptional +end + # A string primary key, where find casting an id it was handed raw fails # outright rather than coercing the wrapper down a soft-deprecated path. class Tag < ActiveRecord::Base @@ -1346,6 +1354,43 @@ def test_form_helpers_render_what_an_unconverted_record_renders end end + # A query method reads the record through the public reader and matches + # what it finds against true, then false and nil, before falling through to + # blankness. An Option is none of those, and its blankness is its + # discriminant rather than its value, so an explicit false and a stored zero + # would answer true where the unconverted twin answers false. + def test_a_generated_query_method_answers_what_the_unconverted_model_answers + each_queried_value do |name, value| + assert_equal PlainNote.new(name => value).public_send(:"#{name}?"), + Note.new(name => value).public_send(:"#{name}?"), + "#{name}? for #{value.inspect}" + end + end + + # Rails copies query_attribute into the private attribute? at the alias, and + # the generated methods call that copy, so an override has to reach both. + def test_query_attribute_and_its_private_alias_answer_what_the_unconverted_model_answers + each_queried_value do |name, value| + expected = PlainNote.new(name => value).query_attribute(name) + note = Note.new(name => value) + + assert_equal expected, note.query_attribute(name), "query_attribute(#{name.inspect}) for #{value.inspect}" + assert_equal expected, note.send(:attribute?, name), "attribute?(#{name.inspect}) for #{value.inspect}" + end + end + + # An opted-out column reads as a plain value on a converted model, so the + # query methods have nothing to unwrap and answer as they always did. + def test_an_opted_out_column_answers_the_query_methods_unchanged + [0.0, 1.5, nil].each do |value| + expected = PlainNote.new(score: value).query_attribute(:score) + note = PartlyPlainNote.new(score: value) + + assert_equal expected, note.score?, "score? for #{value.inspect}" + assert_equal expected, note.query_attribute(:score), "query_attribute(:score) for #{value.inspect}" + end + end + # to_option lifts a value that may be nil. An Option is already lifted, and # a second lift nests invisibly: Some(Some(x)) still answers some?, so the # mistake surfaces far from where it was made. @@ -2393,6 +2438,16 @@ def test_try_bang_raises_where_try_answers_nil private + # One nullable column per arm of ActiveRecord's query cast: a boolean it + # matches outright, a number it asks for zero, a string it weighs for + # blankness. Each value is one an unconverted model answers false for, one + # it answers true for, and absence. + def each_queried_value + { pinned: [false, true, nil], rank: [0, 3, nil], title: ['', 'Wanderer', nil] }.each do |name, values| + values.each { |value| yield name, value } + end + end + def declare_serialize_none(mode, **scope) Class.new(ActiveRecord::Base) do self.table_name = 'books' diff --git a/test/reproductions/issue_88_test.rb b/test/reproductions/issue_88_test.rb deleted file mode 100644 index b7835d5..0000000 --- a/test/reproductions/issue_88_test.rb +++ /dev/null @@ -1,75 +0,0 @@ -# frozen_string_literal: true - -# The Rails integration test owns the connection, the schema and the engine -# wiring for this process; the reproduction adds a table of its own to it. -require_relative '../rails_test' - -ActiveRecord::Schema.define do - create_table 'staff_records', force: :cascade do |t| - t.boolean :staff_access - t.integer :credits - t.string :handle - end -end - -class PlainStaffRecord < ActiveRecord::Base - self.table_name = 'staff_records' -end - -class WrappedStaffRecord < ActiveRecord::Base - self.table_name = 'staff_records' - include Errgonomic::Rails::ActiveRecordOptional -end - -# Issue #88 claims the Rails-generated `attr?` query method answers true for a -# wrapped `Some(false)` and a wrapped `Some(0)`, because ActiveRecord's -# `query_attribute` reads the wrapped reader and falls through to -# `!value.blank?`, and an Option's blankness is its discriminant. The expected -# value in each case is what an unconverted model answers. -class Issue88QueryAttributeTest < Minitest::Test - def test_plain_active_record_answers_false_for_an_explicit_false - refute_predicate PlainStaffRecord.new(staff_access: false), :staff_access? - end - - def test_a_wrapped_nullable_boolean_false_answers_false - refute_predicate WrappedStaffRecord.new(staff_access: false), :staff_access? - end - - def test_plain_active_record_answers_false_for_a_stored_zero - refute_predicate PlainStaffRecord.new(credits: 0), :credits? - end - - def test_a_wrapped_nullable_integer_zero_answers_false - refute_predicate WrappedStaffRecord.new(credits: 0), :credits? - end - - def test_plain_active_record_answers_false_for_an_empty_string - refute_predicate PlainStaffRecord.new(handle: ''), :handle? - end - - def test_a_wrapped_empty_string_answers_false - refute_predicate WrappedStaffRecord.new(handle: ''), :handle? - end - - def test_plain_active_record_answers_false_for_nil - refute_predicate PlainStaffRecord.new, :staff_access? - end - - def test_a_wrapped_nil_answers_false - refute_predicate WrappedStaffRecord.new, :staff_access? - end - - def test_a_wrapped_true_still_answers_true - assert_predicate WrappedStaffRecord.new(staff_access: true), :staff_access? - end - - # The generated `attr?` methods dispatch through the private `attribute?` - # alias rather than `query_attribute`, so both are read here. - def test_the_private_query_attribute_reader_answers_false - refute WrappedStaffRecord.new(staff_access: false).send(:query_attribute, 'staff_access') - end - - def test_the_private_attribute_query_alias_answers_false - refute WrappedStaffRecord.new(staff_access: false).send(:attribute?, 'staff_access') - end -end From d4b5ef368a94b769b9d407a6cf223cbb59461f9b Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Wed, 16 Sep 2026 12:08:49 -0500 Subject: [PATCH 03/10] Unwrap the reader a query method casts ActiveRecord matches the reader's value against true, then false and nil, before falling through to blankness, and an Option is none of those: an explicit false and a stored zero answered true. Rails copies query_attribute into the private attribute? at the alias, and the generated attr? methods call that copy, so the override is aliased the same way to reach them. --- .../rails/active_record_optional.rb | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/lib/errgonomic/rails/active_record_optional.rb b/lib/errgonomic/rails/active_record_optional.rb index 6279ef1..dcf0dcf 100644 --- a/lib/errgonomic/rails/active_record_optional.rb +++ b/lib/errgonomic/rails/active_record_optional.rb @@ -38,10 +38,12 @@ module Rails # Validation unwraps at read_attribute_for_validation, the seam every # EachValidator fetches an attribute through; serialization at # read_attribute_for_serialization, the seam every attribute in a - # payload is fetched through; and a form helper at ActionView's tag - # value, the seam every field reads its record through. So a standard - # validator weighs the value, a payload carries it and a form renders - # it, rather than the wrapper. A singular association with nested + # payload is fetched through; a form helper at ActionView's tag + # value, the seam every field reads its record through; and a query + # method at query_attribute, the seam every generated attr? is cast + # through. So a standard validator weighs the value, a payload carries + # it, a form renders it and a query method answers for it, rather than + # for the wrapper. A singular association with nested # attributes goes further and keeps its plain reader: nested attributes # are assigned through the reader, and ActiveRecord asks whatever it # finds there whether it is a new record. So does a reader a framework @@ -115,6 +117,29 @@ def serializable_hash(options = nil) errgonomic_omit_absent_keys(hash) end + # A query method reads the record through the public reader and matches + # what it finds against true, then false and nil, before falling through + # to blankness. An Option is none of those, and its blankness is its + # discriminant rather than its value, so an explicit false and a stored + # zero would answer true. Unwrapping the read is what keeps the answer + # the one an unconverted model gives. + # + # @example + # Note.new(pinned: false).pinned? # => false + # Note.new(rank: 0).rank? # => false + # Note.new(title: '').title? # => false + # Note.new(pinned: true).pinned? # => true + # Note.new(rank: 3).rank? # => true + def query_attribute(attr_name) + query_cast_attribute(attr_name, Errgonomic::Rails.unwrap_option(public_send(attr_name))) + end + + # Rails copies query_attribute into attribute? at the alias, and the + # attr? methods it generates call that copy, so an override of the one + # never reaches them. + alias attribute? query_attribute + private :attribute? + # YARD does not see through a concern's class_methods block, so the # method it documents is declared rather than read. # From bf92a683736937fdbdf1c6d6bb30d66e7b4d5375 Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Wed, 16 Sep 2026 11:57:45 -0500 Subject: [PATCH 04/10] Reproduce #89's claim that a delegated predicate answers Some(false) and None Rails' delegate ... allow_nil: true is declared on an unconverted model beside it, so the two contracts for the same shape read together, and some_and? records the spelling Sprout fell back to. --- test/reproductions/issue_89_test.rb | 92 +++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 test/reproductions/issue_89_test.rb diff --git a/test/reproductions/issue_89_test.rb b/test/reproductions/issue_89_test.rb new file mode 100644 index 0000000..01fd671 --- /dev/null +++ b/test/reproductions/issue_89_test.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +# The Rails integration test owns the connection, the schema and the engine +# wiring for this process; the reproduction adds tables of its own to it. +require_relative '../rails_test' + +ActiveRecord::Schema.define do + create_table 'probe_tiers', force: :cascade do |t| + t.boolean :enterprise + end + + create_table 'probe_accounts', force: :cascade do |t| + t.references :probe_tier + end +end + +class ProbeTier < ActiveRecord::Base + self.table_name = 'probe_tiers' +end + +class ProbeAccount < ActiveRecord::Base + self.table_name = 'probe_accounts' + belongs_to :probe_tier, optional: true + include Errgonomic::Rails::ActiveRecordOptional + delegate_optional :enterprise?, to: :probe_tier +end + +# The contract delegate_optional is a swap for, on a model that is not +# converted, so the two answers for the same shape sit side by side. +class PlainProbeAccount < ActiveRecord::Base + self.table_name = 'probe_accounts' + belongs_to :probe_tier, optional: true + delegate :enterprise?, to: :probe_tier, allow_nil: true +end + +# Issue #89 claims a delegated predicate answers Some(false) for a false +# target and None for an absent one, both of which are truthy, so +# `if account.enterprise?` takes the true branch with no exception raised. +class Issue89DelegatedPredicateTest < Minitest::Test + def setup + @enterprise = ProbeTier.create!(enterprise: true) + @hobby = ProbeTier.create!(enterprise: false) + end + + def test_the_issues_example_on_a_true_target + assert_equal 'Some(true)', ProbeAccount.new(probe_tier: @enterprise).enterprise?.inspect + end + + def test_the_issues_example_on_a_false_target + assert_equal 'Some(false)', ProbeAccount.new(probe_tier: @hobby).enterprise?.inspect + end + + def test_the_issues_example_on_an_absent_target + assert_equal 'None', ProbeAccount.new.enterprise?.inspect + end + + def test_a_delegated_predicate_on_a_false_target_is_falsey + account = ProbeAccount.new(probe_tier: @hobby) + + refute account.enterprise?, "took the true branch; the predicate answered #{account.enterprise?.inspect}" + end + + def test_a_delegated_predicate_on_an_absent_target_is_falsey + account = ProbeAccount.new + + refute account.enterprise?, "took the true branch; the predicate answered #{account.enterprise?.inspect}" + end + + def test_a_delegated_predicate_on_a_true_target_is_truthy + assert ProbeAccount.new(probe_tier: @enterprise).enterprise? + end + + def test_rails_delegate_allow_nil_answers_a_bare_boolean_for_a_true_target + assert_equal true, PlainProbeAccount.new(probe_tier: @enterprise).enterprise? + end + + def test_rails_delegate_allow_nil_answers_a_bare_boolean_for_a_false_target + assert_equal false, PlainProbeAccount.new(probe_tier: @hobby).enterprise? + end + + def test_rails_delegate_allow_nil_answers_nil_for_an_absent_target + assert_nil PlainProbeAccount.new.enterprise? + end + + # The spelling Sprout fell back to, and the shape the issue's thread + # proposes the predicate form take. + def test_some_and_answers_a_bare_boolean_for_every_shape + assert ProbeAccount.new(probe_tier: @enterprise).probe_tier.some_and?(&:enterprise?) + refute ProbeAccount.new(probe_tier: @hobby).probe_tier.some_and?(&:enterprise?) + refute ProbeAccount.new.probe_tier.some_and?(&:enterprise?) + end +end From 486f6746c56bb3443827f4338b673179082a6f86 Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Wed, 16 Sep 2026 12:16:39 -0500 Subject: [PATCH 05/10] Move #89's reproduction beside delegate_optional's own tests The reproduction pinned the defect as it stands; beside the other delegation tests it states the contract instead: a delegated ? method answers a bare boolean, so an `if` branches the way it branches under `delegate ... allow_nil: true`. The probe target answers every shape a predicate can answer, including nil, a String and a raise, which a boolean column cannot produce. --- test/rails_test.rb | 112 ++++++++++++++++++++++++++++ test/reproductions/issue_89_test.rb | 92 ----------------------- 2 files changed, 112 insertions(+), 92 deletions(-) delete mode 100644 test/reproductions/issue_89_test.rb diff --git a/test/rails_test.rb b/test/rails_test.rb index 5ed14b4..ad06ad9 100644 --- a/test/rails_test.rb +++ b/test/rails_test.rb @@ -176,6 +176,16 @@ def ActionText.table_name_prefix = 'action_text_' t.datetime :read_at t.timestamps end + + create_table 'probe_tiers', force: :cascade do |t| + t.boolean :enterprise + t.string :headline + t.string :features + end + + create_table 'probe_accounts', force: :cascade do |t| + t.references :probe_tier + end end ActiveRecord::Encryption.configure( @@ -304,6 +314,40 @@ class Bulletin < ActiveRecord::Base delegate_optional :name, to: :author, prefix: true end +# A delegation target whose predicates answer every shape a predicate can +# answer, including the ones a boolean column cannot: nil, a String, and a +# raise. The bare readers stand beside them as the non-predicate control. +class ProbeTier < ActiveRecord::Base + self.table_name = 'probe_tiers' + + # The column is nullable, so this is true, false or nil. + def enterprise? = enterprise + + # A ? method that answers a value rather than a verdict. + def headline? = headline + + def includes?(feature) = features.to_s.split(',').include?(feature.to_s) + + def boom? = raise(ArgumentError, 'boom') +end + +class ProbeAccount < ActiveRecord::Base + self.table_name = 'probe_accounts' + belongs_to :probe_tier, optional: true + include Errgonomic::Rails::ActiveRecordOptional + delegate_optional :enterprise?, :headline?, :includes?, :boom?, to: :probe_tier + delegate_optional :headline, to: :probe_tier + delegate_optional :persisted?, :valid?, to: :probe_tier, prefix: true +end + +# The contract delegate_optional is a swap for, on a model that is not +# converted, so the two answers for the same shape sit side by side. +class PlainProbeAccount < ActiveRecord::Base + self.table_name = 'probe_accounts' + belongs_to :probe_tier, optional: true + delegate :enterprise?, :headline?, :includes?, :boom?, :headline, to: :probe_tier, allow_nil: true +end + # A buggy layer that re-enters the attribute reader from beneath it: the # generated reader's super lands here, and the unqualified call restarts # dispatch at the top of the chain. @@ -1431,6 +1475,74 @@ def test_recursive_read_raises_a_named_error_at_first_reentry assert_match(/bio/, error.message) end + # A predicate is asked for a verdict, and a verdict has to be usable as + # one: an Option is always truthy, so Some(false) and None both take the + # true branch of an `if`. A delegated ? method answers a bare boolean, the + # way the Rails delegation it is a swap for does. + def test_a_delegated_predicate_answers_a_bare_boolean + yes = ProbeTier.create!(enterprise: true) + no = ProbeTier.create!(enterprise: false) + unset = ProbeTier.create! + + assert_equal true, ProbeAccount.new(probe_tier: yes).enterprise? + assert_equal false, ProbeAccount.new(probe_tier: no).enterprise? + assert_equal false, ProbeAccount.new(probe_tier: unset).enterprise? + assert_equal false, ProbeAccount.new.enterprise? + end + + # The same declaration on an unconverted model, so the swap is visible. + # Rails answers nil for an absent target where this answers false; both + # are falsey, so no `if` changes branch. + def test_a_delegated_predicate_branches_the_way_rails_delegate_branches + [ProbeTier.create!(enterprise: true), ProbeTier.create!(enterprise: false), ProbeTier.create!, nil].each do |tier| + converted = ProbeAccount.new(probe_tier: tier).enterprise? ? :then : :else + plain = PlainProbeAccount.new(probe_tier: tier).enterprise? ? :then : :else + + assert_equal plain, converted, "branched differently for #{tier.inspect}" + end + end + + # A bare reader beside the predicate: only the ? name is special-cased. + def test_a_delegated_reader_still_answers_an_option + tier = ProbeTier.create!(headline: 'Big News') + + assert_equal Some('Big News'), ProbeAccount.new(probe_tier: tier).headline + assert ProbeAccount.new.headline.none? + end + + # A predicate that takes an argument is still a predicate. + def test_a_delegated_predicate_forwards_its_arguments + tier = ProbeTier.create!(features: 'sso,audit') + + assert_equal true, ProbeAccount.new(probe_tier: tier).includes?('sso') + assert_equal false, ProbeAccount.new(probe_tier: tier).includes?('scim') + assert_equal false, ProbeAccount.new.includes?('sso') + end + + # An absent target answers false without calling anything, so a method + # that raises raises only where it is reached. + def test_a_delegated_predicate_raises_only_when_the_target_is_there + assert_raises(ArgumentError) { ProbeAccount.new(probe_tier: ProbeTier.create!).boom? } + assert_equal false, ProbeAccount.new.boom? + end + + # A ? method answering a value rather than a verdict is read as a verdict: + # the value is tested for truth and does not survive the delegation. + def test_a_delegated_predicate_answering_a_value_answers_its_truth + assert_equal true, ProbeAccount.new(probe_tier: ProbeTier.create!(headline: 'Big News')).headline? + assert_equal true, ProbeAccount.new(probe_tier: ProbeTier.create!(headline: '')).headline? + assert_equal false, ProbeAccount.new(probe_tier: ProbeTier.create!).headline? + end + + # Rails' own predicates delegate on the same terms as any other. + def test_rails_own_predicates_delegate_as_predicates + account = ProbeAccount.new(probe_tier: ProbeTier.create!) + + assert_equal true, account.probe_tier_persisted? + assert_equal true, account.probe_tier_valid? + assert_equal false, ProbeAccount.new.probe_tier_persisted? + end + def test_private_delegate_optional fiction = Genre.create!(name: 'Fiction') scifi = Genre.create!(name: 'Sci-Fi', parent: fiction) diff --git a/test/reproductions/issue_89_test.rb b/test/reproductions/issue_89_test.rb deleted file mode 100644 index 01fd671..0000000 --- a/test/reproductions/issue_89_test.rb +++ /dev/null @@ -1,92 +0,0 @@ -# frozen_string_literal: true - -# The Rails integration test owns the connection, the schema and the engine -# wiring for this process; the reproduction adds tables of its own to it. -require_relative '../rails_test' - -ActiveRecord::Schema.define do - create_table 'probe_tiers', force: :cascade do |t| - t.boolean :enterprise - end - - create_table 'probe_accounts', force: :cascade do |t| - t.references :probe_tier - end -end - -class ProbeTier < ActiveRecord::Base - self.table_name = 'probe_tiers' -end - -class ProbeAccount < ActiveRecord::Base - self.table_name = 'probe_accounts' - belongs_to :probe_tier, optional: true - include Errgonomic::Rails::ActiveRecordOptional - delegate_optional :enterprise?, to: :probe_tier -end - -# The contract delegate_optional is a swap for, on a model that is not -# converted, so the two answers for the same shape sit side by side. -class PlainProbeAccount < ActiveRecord::Base - self.table_name = 'probe_accounts' - belongs_to :probe_tier, optional: true - delegate :enterprise?, to: :probe_tier, allow_nil: true -end - -# Issue #89 claims a delegated predicate answers Some(false) for a false -# target and None for an absent one, both of which are truthy, so -# `if account.enterprise?` takes the true branch with no exception raised. -class Issue89DelegatedPredicateTest < Minitest::Test - def setup - @enterprise = ProbeTier.create!(enterprise: true) - @hobby = ProbeTier.create!(enterprise: false) - end - - def test_the_issues_example_on_a_true_target - assert_equal 'Some(true)', ProbeAccount.new(probe_tier: @enterprise).enterprise?.inspect - end - - def test_the_issues_example_on_a_false_target - assert_equal 'Some(false)', ProbeAccount.new(probe_tier: @hobby).enterprise?.inspect - end - - def test_the_issues_example_on_an_absent_target - assert_equal 'None', ProbeAccount.new.enterprise?.inspect - end - - def test_a_delegated_predicate_on_a_false_target_is_falsey - account = ProbeAccount.new(probe_tier: @hobby) - - refute account.enterprise?, "took the true branch; the predicate answered #{account.enterprise?.inspect}" - end - - def test_a_delegated_predicate_on_an_absent_target_is_falsey - account = ProbeAccount.new - - refute account.enterprise?, "took the true branch; the predicate answered #{account.enterprise?.inspect}" - end - - def test_a_delegated_predicate_on_a_true_target_is_truthy - assert ProbeAccount.new(probe_tier: @enterprise).enterprise? - end - - def test_rails_delegate_allow_nil_answers_a_bare_boolean_for_a_true_target - assert_equal true, PlainProbeAccount.new(probe_tier: @enterprise).enterprise? - end - - def test_rails_delegate_allow_nil_answers_a_bare_boolean_for_a_false_target - assert_equal false, PlainProbeAccount.new(probe_tier: @hobby).enterprise? - end - - def test_rails_delegate_allow_nil_answers_nil_for_an_absent_target - assert_nil PlainProbeAccount.new.enterprise? - end - - # The spelling Sprout fell back to, and the shape the issue's thread - # proposes the predicate form take. - def test_some_and_answers_a_bare_boolean_for_every_shape - assert ProbeAccount.new(probe_tier: @enterprise).probe_tier.some_and?(&:enterprise?) - refute ProbeAccount.new(probe_tier: @hobby).probe_tier.some_and?(&:enterprise?) - refute ProbeAccount.new.probe_tier.some_and?(&:enterprise?) - end -end From ff521792ac47a721189872c4b431fb399bfe2765 Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Wed, 16 Sep 2026 12:18:28 -0500 Subject: [PATCH 06/10] Answer a delegated predicate with a bare boolean A ? name asks for a verdict, and an Option cannot serve as one: every Option is truthy, so Some(false) and None both take the true branch of an `if`, silently. The generated reader for a ? method now tests the target through some_and?, which answers true or false and reads an absent target as false. nil and false branch alike, so the swap from `delegate ... allow_nil: true` still branches identically. --- .../rails/active_record_delegate_optional.rb | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/lib/errgonomic/rails/active_record_delegate_optional.rb b/lib/errgonomic/rails/active_record_delegate_optional.rb index 9a29ed1..54bb9d8 100644 --- a/lib/errgonomic/rails/active_record_delegate_optional.rb +++ b/lib/errgonomic/rails/active_record_delegate_optional.rb @@ -158,11 +158,25 @@ def delegate_optional(*methods, to: nil, prefix: nil, private: nil, allow_nil: n def define_optional_delegation(receiver, method_name, reader, declared_at) class_eval <<-RUBY, declared_at.path, declared_at.lineno # rubocop:disable Style/EvalWithLocation def #{reader}(...) - #{receiver}.to_option.and_then { |target| target.#{method_name}(...).to_option } + #{delegation_body(receiver, method_name)} end RUBY end + # A ? name asks for a verdict, and an Option is not usable as one: + # every Option is truthy, so Some(false) and None both take the true + # branch. A predicate therefore answers a bare boolean, reading an + # absent target as false, which branches the way nil does. + def delegation_body(receiver, method_name) + return "#{receiver}.to_option.some_and? { |target| target.#{method_name}(...) }" if predicate?(method_name) + + "#{receiver}.to_option.and_then { |target| target.#{method_name}(...).to_option }" + end + + def predicate?(method_name) + method_name.to_s.end_with?('?') + end + # A target named for a Ruby keyword reads as the keyword in the body # it is written into, so it needs an explicit receiver. Rails answers # the same question for delegate, and answers it for the same names. From 6184993f2f6d56ae8ba1c0d564f4c42c1a88ee25 Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Wed, 16 Sep 2026 12:16:53 -0500 Subject: [PATCH 07/10] Keep the coercion contract as a test of its own An Option defining no coerce, to_str or to_ary is a decided constraint with no coverage elsewhere; the match-operator and strict-equality messages the same file pinned are already doctested. --- test/option_coercion_test.rb | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 test/option_coercion_test.rb diff --git a/test/option_coercion_test.rb b/test/option_coercion_test.rb new file mode 100644 index 0000000..d2cec22 --- /dev/null +++ b/test/option_coercion_test.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'minitest/autorun' +require_relative '../lib/errgonomic' + +# An Option implements no coercion protocol, so Ruby's own numeric coercion +# refuses it rather than reaching the inner value. Defining coerce, to_str or +# to_ary would make Array(), splat, flatten and arithmetic treat a wrapper as +# its payload, which is the silent unwrap the library exists to prevent. +class OptionCoercionTest < Minitest::Test + TIME_COERCION_MESSAGE = "can't convert Errgonomic::Option::Some into an exact number" + INTEGER_COERCION_MESSAGE = "Errgonomic::Option::Some can't be coerced into Integer" + + def test_time_arithmetic_raises_a_type_error_naming_the_class + error = assert_raises(TypeError) { Time.at(0) - Some(Time.at(0)) } + + assert_equal TIME_COERCION_MESSAGE, error.message + end + + def test_integer_arithmetic_raises_a_type_error_naming_the_class + error = assert_raises(TypeError) { 1 - Some(1) } + + assert_equal INTEGER_COERCION_MESSAGE, error.message + end + + def test_an_option_defines_none_of_the_coercion_protocols + refute_respond_to Some(1), :coerce + refute_respond_to Some('x'), :to_str + refute_respond_to Some([1]), :to_ary + end +end From 21652967bc33cbb4e939d0758a60eacd80f5038f Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Wed, 16 Sep 2026 12:27:12 -0500 Subject: [PATCH 08/10] Document that a delegated predicate answers a bare boolean The README said a delegate declaration swaps over unchanged unless it delegates a writer, which a predicate now also breaks. The doctest delegates a ? method for the first time, so the convention has an executable example rather than only prose. --- README.md | 2 +- doctest_helper.rb | 5 +++++ lib/errgonomic/rails/active_record_delegate_optional.rb | 5 +++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3241f34..53c9a93 100644 --- a/README.md +++ b/README.md @@ -403,7 +403,7 @@ The declaration reads as well above the include as below it, as `errgonomic_opti `Model.errgonomic_optionals` reports which readers a model wrapped, including nullable foreign-key columns, so `book.author_id` is `Some(1)` alongside `book.author`. That is how to check that a conversion did what it meant to. A subclass reports what it inherited alongside anything it wrapped itself, so the report names every wrapped reader the class responds to; `Model.errgonomic_optional_names` is the set that class wrapped on its own. -`delegate_optional` is Rails' `delegate` with `allow_nil`, where the absent case is a `None` rather than a `nil`: `delegate_optional :name, to: :author` gives `book.name # => Some('Cixin Liu')`, and `None()` where there is no author. The prefix forms are Rails': `prefix: true` names the reader after the target (`author_name`), and `prefix: :writer` names it `writer_name`. `private: true` works as it does there. The reader forwards whatever it was called with, arguments and block alike. `allow_nil: true` is accepted and says nothing new, so a `delegate` declaration swaps over unchanged unless it delegates a writer. `delegate_optional :name=, to: :author` raises instead: an assignment through an absent target has nowhere to put the value, and dropping it silently is what the type is there to prevent. `allow_nil: false` asks for a reader that raises on absence, which this does not have, so it raises `ArgumentError` where it is written, as a declaration with no `to:` does. +`delegate_optional` is Rails' `delegate` with `allow_nil`, where the absent case is a `None` rather than a `nil`: `delegate_optional :name, to: :author` gives `book.name # => Some('Cixin Liu')`, and `None()` where there is no author. The prefix forms are Rails': `prefix: true` names the reader after the target (`author_name`), and `prefix: :writer` names it `writer_name`. `private: true` works as it does there. The reader forwards whatever it was called with, arguments and block alike. `allow_nil: true` is accepted and says nothing new, so a `delegate` declaration swaps over unchanged unless it delegates a writer or a predicate. A name ending in `?` asks for a verdict, and no Option can serve as one, because every Option is truthy. So a predicate answers a bare `true` or `false` through `some_and?`: `delegate_optional :prolific?, to: :author` gives `book.prolific? # => false` for an unprolific author, and `false` again where there is no author, which branches an `if` the way Rails' `allow_nil: true` does. The one thing that costs is a `?` method that answers a value by design, which now answers `true`; declare it without the `?`, or write `some_and?` by hand. `delegate_optional :name=, to: :author` raises instead: an assignment through an absent target has nowhere to put the value, and dropping it silently is what the type is there to prevent. `allow_nil: false` asks for a reader that raises on absence, which this does not have, so it raises `ArgumentError` where it is written, as a declaration with no `to:` does. It is available on every model, converted or not, because it lifts both ends one layer. The target is lifted, so a plain record reads as `Some` and a `nil` as `None`. What the delegated call returns is lifted too, so a delegated reader that is itself an Option comes back as one Option rather than two. diff --git a/doctest_helper.rb b/doctest_helper.rb index 6ed43e1..db4a89a 100644 --- a/doctest_helper.rb +++ b/doctest_helper.rb @@ -76,6 +76,10 @@ def greeting(salutation, punctuation: '.') def styled_name yield(name) end + + def credited? + !bio.nil? + end end # A converted model reads its association as an Option. @@ -86,6 +90,7 @@ class Article < ActiveRecord::Base delegate_optional :name, to: :writer, prefix: :author delegate_optional :bio, to: :writer delegate_optional :greeting, :styled_name, to: :writer, prefix: true + delegate_optional :credited?, to: :writer delegate_optional :table_name, to: :class end diff --git a/lib/errgonomic/rails/active_record_delegate_optional.rb b/lib/errgonomic/rails/active_record_delegate_optional.rb index 54bb9d8..76fa2fb 100644 --- a/lib/errgonomic/rails/active_record_delegate_optional.rb +++ b/lib/errgonomic/rails/active_record_delegate_optional.rb @@ -27,6 +27,11 @@ module ActiveRecordDelegateOptional # @!method delegate_optional(*methods, to: nil, prefix: nil, private: nil, allow_nil: nil) # @!scope class # Delegates to an optional target, answering an Option: None where the target is absent. + # A name ending in `?` is the exception: it answers a bare boolean, false for an absent target. + # @example a predicate answers a verdict rather than an Option, since every Option is truthy + # Article.create!(title: 'Omelas', writer: Writer.create!(name: 'Ursula', bio: 'writes')).credited? # => true + # Article.create!(title: 'Omelas', writer: Writer.create!(name: 'Ursula')).credited? # => false + # Article.create!(title: 'Untitled').credited? # => false # @example prefix forms name the reader, as they do for Rails' delegate # article = Article.create!(title: 'Omelas', writer: Writer.create!(name: 'Ursula', bio: 'writes')) # article.writer_name # => Some('Ursula') From d00daa0d1f453154e106d8eed8f038b0e39d4f71 Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Wed, 16 Sep 2026 12:46:57 -0500 Subject: [PATCH 09/10] Name what to_ary would add that to_a already answers Array() and a splat reach an Option's value today, through to_a, so the note claiming to_ary would newly unwrap them was wrong about two of the four cases it listed. The assertion pins Array()'s answer so the seam is recorded either way. --- test/option_coercion_test.rb | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/test/option_coercion_test.rb b/test/option_coercion_test.rb index d2cec22..a666606 100644 --- a/test/option_coercion_test.rb +++ b/test/option_coercion_test.rb @@ -4,9 +4,11 @@ require_relative '../lib/errgonomic' # An Option implements no coercion protocol, so Ruby's own numeric coercion -# refuses it rather than reaching the inner value. Defining coerce, to_str or -# to_ary would make Array(), splat, flatten and arithmetic treat a wrapper as -# its payload, which is the silent unwrap the library exists to prevent. +# refuses it rather than reaching the inner value. Array() and splat already +# reach the value, through to_a. Defining to_ary would extend that to flatten, +# multiple assignment, Array#+ and block destructuring, and defining coerce or +# to_str would extend it to arithmetic and to string conversion. Each of those +# is the silent unwrap the library exists to prevent. class OptionCoercionTest < Minitest::Test TIME_COERCION_MESSAGE = "can't convert Errgonomic::Option::Some into an exact number" INTEGER_COERCION_MESSAGE = "Errgonomic::Option::Some can't be coerced into Integer" @@ -23,6 +25,10 @@ def test_integer_arithmetic_raises_a_type_error_naming_the_class assert_equal INTEGER_COERCION_MESSAGE, error.message end + def test_array_conversion_reaches_the_value_through_to_a + assert_equal [1], Array(Some(1)) + end + def test_an_option_defines_none_of_the_coercion_protocols refute_respond_to Some(1), :coerce refute_respond_to Some('x'), :to_str From 1b63dc6557f9fc8ce01322e7d5ad96acd961aaf6 Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Wed, 16 Sep 2026 12:27:59 -0500 Subject: [PATCH 10/10] Release 0.11.0 A minor bump rather than a patch: a delegated predicate's return type changes from Option to boolean, which is a public contract. --- CHANGELOG.md | 13 +++++++++++++ Gemfile.lock | 4 ++-- lib/errgonomic/version.rb | 2 +- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf4cfc5..e294baa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ ## [Unreleased] +## [0.11.0] - 2026-09-16 + +This release makes a converted model's query methods and a delegated predicate answer the verdict a boolean caller asked for, rather than something an `if` reads as true. + +### Upgrading from 0.10.2 + +An application that prepended its own `query_attribute` unwrap to work around a wrapped column's `attr?` answering true for `false`, `0` or `""` can delete it: the concern does it now, and a prepended copy is redundant rather than harmful. A `delegate_optional` whose method name ends in `?` returns `true` or `false` where it returned an Option, so `some?`, `unwrap_or` and the other combinators on that result are a `NoMethodError`. Read the boolean instead. The one loss is a `?` method that answers a value by design, which now answers `true`: declare it without the `?`, or write `some_and?` by hand. The seam to watch is `Object#to_option`, which lifts whatever it is handed, so `record.delegated?.to_option` is `Some(false)` for an absent target where it was `None`. + +### Changes + +- [Bug fix] A converted model's `query_attribute`, its private `attribute?` alias and the `attr?` methods Rails generates from them answer what the unconverted model answers for a wrapped column, so `note.pinned?` is `false` for an explicit `false` and `note.rank?` is `false` for a `0`. The concern overrides `query_attribute` to unwrap the reader's Option before handing the value to Rails' own cast. Previously the cast saw a `Some`, missed its `true` and `false, nil` arms and fell through to `!blank?`, where `Option#blank?` is `none?`, so every present value answered true ([#88](https://github.com/omc/errgonomic/issues/88)) +- [Behavior change] `delegate_optional` with a method name ending in `?` answers `true` or `false` through `some_and?`, reading an absent target as `false`, where it answered `Some(true)`, `Some(false)` and `None` before. A `?` name asks for a verdict and no Option can serve as one, because every Option is truthy: `Some(false)` and `None` both took the true branch of an `if`, which is the silent wrong branch that `delegate ..., allow_nil: true` does not have. Every other name is lifted into an Option as before, and there is no opt-out keyword ([#89](https://github.com/omc/errgonomic/issues/89)) + ## [0.10.2] - 2026-09-10 This release makes every `Err` carry an error and removes `Option#ok`, the one method that built an `Err` without one. diff --git a/Gemfile.lock b/Gemfile.lock index 3249b50..136f2f6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - errgonomic (0.10.2) + errgonomic (0.11.0) concurrent-ruby (~> 1.0) GEM @@ -385,7 +385,7 @@ CHECKSUMS diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 erb (6.0.6) sha256=a9b24986700f5bf127c4f297c5403c3ca41b83b0a316c0cd09a096b56e644ae5 - errgonomic (0.10.2) + errgonomic (0.11.0) erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 globalid (1.4.0) sha256=037f12fbf1d9d7a014d501c2d5c77356fd4ddd96d7a7991d6700bba96706f427 highline (3.0.1) sha256=ca18b218fd581b1fae832f89bfeaf2b34d3a93429c44fd4411042ffce286f009 diff --git a/lib/errgonomic/version.rb b/lib/errgonomic/version.rb index 497af54..fef8d0c 100644 --- a/lib/errgonomic/version.rb +++ b/lib/errgonomic/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Errgonomic - VERSION = '0.10.2' + VERSION = '0.11.0' end