Skip to content
Merged
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
4 changes: 2 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
errgonomic (0.10.2)
errgonomic (0.11.0)
concurrent-ruby (~> 1.0)

GEM
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
5 changes: 5 additions & 0 deletions doctest_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down
21 changes: 20 additions & 1 deletion lib/errgonomic/rails/active_record_delegate_optional.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -158,11 +163,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.
Expand Down
33 changes: 29 additions & 4 deletions lib/errgonomic/rails/active_record_optional.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
#
Expand Down
2 changes: 1 addition & 1 deletion lib/errgonomic/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module Errgonomic
VERSION = '0.10.2'
VERSION = '0.11.0'
end
37 changes: 37 additions & 0 deletions test/option_coercion_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# 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. 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"

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_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
refute_respond_to Some([1]), :to_ary
end
end
Loading
Loading