From 13f9b6ffcc4a3a313f156781f2f3bfa2fb296d9f Mon Sep 17 00:00:00 2001 From: Nick Zadrozny Date: Wed, 12 Aug 2026 12:11:24 -0500 Subject: [PATCH] Lifting an Option with to_option returns it unchanged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit to_option lifts a value that may be nil, and an Option is already lifted. Wrapping it again produced Some(Some(x)), which still answers some? and unwraps to an Option, so the nesting surfaced somewhere far from the lift. It shows up wherever a wrapped and an unwrapped source meet — an association that returns nil today, lifted and then reached through for an attribute that is already an Option. --- .../rails/active_record_optional.rb | 12 +++++++++++ test/rails_test.rb | 20 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/lib/errgonomic/rails/active_record_optional.rb b/lib/errgonomic/rails/active_record_optional.rb index 88b6e1a..6fdee38 100644 --- a/lib/errgonomic/rails/active_record_optional.rb +++ b/lib/errgonomic/rails/active_record_optional.rb @@ -223,6 +223,18 @@ def to_option end end +module Errgonomic + module Option + # An Option is already lifted. Lifting it again would nest it, and the + # nesting is invisible until something reaches for the inner value. + class Any + def to_option + self + end + end + end +end + module Errgonomic module Rails # Teach ActiveRecord SQL quoting to unwrap Options, quoting a None as diff --git a/test/rails_test.rb b/test/rails_test.rb index fb9da94..9494f38 100644 --- a/test/rails_test.rb +++ b/test/rails_test.rb @@ -244,6 +244,26 @@ def test_nested_options_and_results_refuse_to_serialize assert_raises(Errgonomic::SerializeError) { [Ok(5)].to_json } 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. + def test_to_option_is_idempotent + assert_equal Some(1), Some(1).to_option + assert_equal 1, Some(1).to_option.unwrap! + assert None().to_option.none? + end + + # The shape an application reaches for around an unwrapped association: + # lift it, then reach through it for an attribute that is already an Option. + def test_to_option_composes_through_an_unwrapped_association + author = Author.create!(name: 'Cixin Liu', bio: 'writes sci-fi') + shelved = Book.create!(title: 'The Dark Forest', author_id: author.id) + unshelved = Book.create!(title: 'Supernova Era') + + assert_equal 'writes sci-fi', shelved.author.to_option.and_then(&:bio).unwrap_or('unknown') + assert_equal 'unknown', unshelved.author.to_option.and_then(&:bio).unwrap_or('unknown') + end + def test_delegate_optional author = Author.create!(name: 'Cixin Liu') book = author.books.create!(title: 'Death\'s End')