Skip to content

Answer a boolean question with a boolean, in query methods and delegated predicates - #91

Merged
nz merged 10 commits into
mainfrom
2026-09-16-errgonomic-round-2-triage/release-0.11.0
Sep 16, 2026
Merged

nz merged 10 commits into
mainfrom
2026-09-16-errgonomic-round-2-triage/release-0.11.0

Conversation

@nz

@nz nz commented Sep 16, 2026

Copy link
Copy Markdown
Member

Two places in the Rails integration answered an if with a value that is always truthy. A converted model's note.pinned? was true for an explicit false, and a delegate_optional :enterprise? handed back Some(false) or None, both of which take the true branch. This release makes both answer true or false, the way the unconverted model and Rails' own delegate ..., allow_nil: true do. It is 0.11.0, and it asks the reviewer to accept one behavior change: a delegated ? reader returns a boolean where it returned an Option.

An application carrying an initializer that prepends its own query_attribute unwrap onto ActiveRecord::Base can delete that initializer once it bumps to 0.11.0. The concern does the unwrap now.

What changes

query_attribute, its private attribute? alias, and the attr? methods Rails generates from them answer what the same column answers on an unconverted model. note.pinned? is false for false, note.rank? is false for 0, and note.title? is false for "". Before, every present value answered true, including false itself. This closes #88.

delegate_optional with a method name ending in ? returns true or false, and reads an absent target as false. Before, it returned Some(true), Some(false) or None, and the last two branched an if the wrong way in silence. Every other method name is lifted into an Option as before. This closes #89.

How it works

For #88, the concern defines query_attribute beside the other unwrapping seams. It unwraps the reader's Option and passes the inner value to Rails' own query_cast_attribute, so the cast stays Rails'. It then aliases the private attribute? to it, because Rails copies query_attribute into attribute? at definition time and compiles a generated pinned? to self.attribute?(:pinned). An override of query_attribute alone never reaches a generated method.

Rails' cast previously received a Some. A Some matches neither the true arm nor the false, nil arm, so the cast fell through to !blank?, and Option#blank? is none?. Every Some was therefore present, and present answered true.

For #89, delegation_body generates receiver.to_option.some_and? { |target| target.m?(...) } for a name that ends in ?, and the previous and_then body for every other name. There is no opt-out keyword. A ? name asks for a verdict, and no Option can serve as one, because every Option is truthy.

The README's delegate_optional section and the method's YARD docstring both state the convention, and a doctest delegates a ? method to prove it.

Evidence

All gates ran locally on aarch64-darwin at the branch head.

  • bundle exec rake test: 156 runs, 702 assertions, 0 failures, 0 errors, 0 skips
  • bundle exec rake yard:doctest: 196 runs, 520 assertions, 0 failures, 0 errors, 0 skips
  • bundle exec rubocop: 25 files inspected, 6 offenses detected, the same six that main reports, none of them in a file this branch changes for the fix
  • nix build .#errgonomic: /nix/store/xndn6j09bnym4x0k9xif45gaqh4mmpr0-errgonomic-0.11.0
  • nix flake check --all-systems: all checks passed!

Each fix is a red commit followed by a green one, so the failing state is in the history. The #88 reproduction fails on main with Expected #<WrappedStaffRecord id: nil, staff_access: false, ...> to not be staff_access?. The #89 reproduction fails on main with six failures.

The tests live beside the code they cover. test/rails_test.rb asserts every #88 case against an unconverted twin model on the same table, rather than against a literal, and covers a column that errgonomic_optional_except keeps unwrapped. It also holds the #89 cases beside the existing delegate_optional tests.

CI runs yard doctest and rake test on Ruby 3.4.x on ubuntu-latest. It does not run rubocop, nix build or nix flake check, so those three are local-only results.

What is left open

The gemspec declares no activerecord dependency and no version bound; its only runtime dependency is concurrent-ruby ~> 1.0. The #88 fix calls query_cast_attribute, a private ActiveRecord method, which was measured on 8.1.3.1, the version Gemfile.lock resolves. Recommendation: accept here. The gem already overrides private Rails API for serialization, and adding a version bound is a separate change with its own reasoning.

Object#to_option lifts whatever it is handed, so record.delegated?.to_option is now Some(false) for an absent target where it was None. That is the one path where the #89 change is silent rather than loud; some?, unwrap_or and unwrap! on a boolean all raise NoMethodError. Recommendation: accept. to_option lifting a boolean is the documented behavior of to_option, and a caller who wants the absent case back can ask the target directly.

A ? method that answers a value by design, such as a headline? that returns a string, now answers true. Recommendation: accept, and note it in the upgrading section, which it is. That method's name misstates its return; the escape is to declare the delegate without the ?, or to write some_and? by hand.

An unprefixed delegate_optional :valid? used to answer Some(true) and so disabled validation on the declaring model in silence. It now validates. That is a behavior change on top of the intended one, and it is an improvement rather than a regression. Recommendation: accept.

nz added 10 commits September 16, 2026 13:10
…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.
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.
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.
…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.
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.
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.
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.
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.
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.
A minor bump rather than a patch: a delegated predicate's return type
changes from Option to boolean, which is a public contract.
@nz
nz marked this pull request as ready for review September 16, 2026 18:17
@nz
nz enabled auto-merge September 16, 2026 18:19

@lutzcc1 lutzcc1 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀

@nz
nz merged commit 25488ca into main Sep 16, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants