Repository navigation
Answer a boolean question with a boolean, in query methods and delegated predicates - #91
Merged
Merged
Conversation
…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
marked this pull request as ready for review
September 16, 2026 18:17
nz
enabled auto-merge
September 16, 2026 18:19
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two places in the Rails integration answered an
ifwith a value that is always truthy. A converted model'snote.pinned?wastruefor an explicitfalse, and adelegate_optional :enterprise?handed backSome(false)orNone, both of which take the true branch. This release makes both answertrueorfalse, the way the unconverted model and Rails' owndelegate ..., allow_nil: truedo. 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_attributeunwrap ontoActiveRecord::Basecan delete that initializer once it bumps to 0.11.0. The concern does the unwrap now.What changes
query_attribute, its privateattribute?alias, and theattr?methods Rails generates from them answer what the same column answers on an unconverted model.note.pinned?isfalseforfalse,note.rank?isfalsefor0, andnote.title?isfalsefor"". Before, every present value answeredtrue, includingfalseitself. This closes #88.delegate_optionalwith a method name ending in?returnstrueorfalse, and reads an absent target asfalse. Before, it returnedSome(true),Some(false)orNone, and the last two branched anifthe 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_attributebeside the other unwrapping seams. It unwraps the reader's Option and passes the inner value to Rails' ownquery_cast_attribute, so the cast stays Rails'. It then aliases the privateattribute?to it, because Rails copiesquery_attributeintoattribute?at definition time and compiles a generatedpinned?toself.attribute?(:pinned). An override ofquery_attributealone never reaches a generated method.Rails' cast previously received a
Some. ASomematches neither thetruearm nor thefalse, nilarm, so the cast fell through to!blank?, andOption#blank?isnone?. EverySomewas therefore present, and present answered true.For #89,
delegation_bodygeneratesreceiver.to_option.some_and? { |target| target.m?(...) }for a name that ends in?, and the previousand_thenbody 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_optionalsection 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 skipsbundle exec rake yard:doctest:196 runs, 520 assertions, 0 failures, 0 errors, 0 skipsbundle exec rubocop:25 files inspected, 6 offenses detected, the same six thatmainreports, none of them in a file this branch changes for the fixnix build .#errgonomic:/nix/store/xndn6j09bnym4x0k9xif45gaqh4mmpr0-errgonomic-0.11.0nix 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
mainwithExpected #<WrappedStaffRecord id: nil, staff_access: false, ...> to not be staff_access?. The #89 reproduction fails onmainwith six failures.The tests live beside the code they cover.
test/rails_test.rbasserts every #88 case against an unconverted twin model on the same table, rather than against a literal, and covers a column thaterrgonomic_optional_exceptkeeps unwrapped. It also holds the #89 cases beside the existingdelegate_optionaltests.CI runs
yard doctestandrake teston Ruby 3.4.x on ubuntu-latest. It does not runrubocop,nix buildornix flake check, so those three are local-only results.What is left open
The gemspec declares no
activerecorddependency and no version bound; its only runtime dependency isconcurrent-ruby ~> 1.0. The #88 fix callsquery_cast_attribute, a private ActiveRecord method, which was measured on 8.1.3.1, the versionGemfile.lockresolves. 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_optionlifts whatever it is handed, sorecord.delegated?.to_optionis nowSome(false)for an absent target where it wasNone. That is the one path where the #89 change is silent rather than loud;some?,unwrap_orandunwrap!on a boolean all raiseNoMethodError. Recommendation: accept.to_optionlifting a boolean is the documented behavior ofto_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 aheadline?that returns a string, now answerstrue. 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 writesome_and?by hand.An unprefixed
delegate_optional :valid?used to answerSome(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.