Repository navigation
Wrap optional readers in a per-class module so model overrides compose - #64
Merged
Merged
Conversation
class_eval put the wrapper where a model writes its own defs, so one silently replaced the other and which survived depended on declaration order: a column wrapper generated at load_schema! clobbered a def in the class body, while a def written below an association macro clobbered the wrapper. Wrapped readers now live in Model::ErrgonomicOptionalReaders, created and included before the class body runs, the way ActiveRecord keeps its generated attribute methods. Include rather than prepend, so a model's own def wins and its super reads the Option.
A has_one override, a reader that declines to call super, an override in a model below a base-class include, a class body that loads the schema before its override, and the wrapped set still reporting a name the model shadows.
The layering rule is what a reader needs to know to override one: the class body sits above the wrapper, so super reads the Option.
An ActiveRecord attribute or association is never an optional of an optional, so a value that arrives already lifted from beneath the wrapper passes through unchanged rather than nesting. Nesting is invisible until something reaches for the inner value, which is the ambiguous failure the type exists to prevent.
An override that keeps the Option is the spelling the docs recommend, so the tests write it that way and assert on the wrapper rather than on an unwrapped String. The one reader that declines to call super stays as it is: owning a plain return value is what that case is for. The comments on two of these now say what the test asserts instead of what an older implementation did.
The gem sits at the intersection of the Rust and Rails idioms rather than translating one into the other, and where it leaves one of them the docs owe a reason. Naming that up front makes the compromises register the place a deviation has to be written down, and gives a design argument something to be judged against before the correct/simple/fast ordering applies.
An Option is an object, so a None survives || and takes the present branch of an if without raising, and safe navigation reaches the wrapper rather than the value. That is the one Rails reflex the type silently defeats, so it is worth naming next to the presence rules and beside unwrap_or, which is the spelling || cannot give you.
An override keeps the Option: super hands back what every other caller gets, and an accessor that returns a plain value is a differently named method beside the reader, the way a Rust display_name sits beside an Option field. The un-idiomatic same-named def that skips super still works, and its two loose ends are now written down. The reader is also where the Option stops. The attribute, dirty tracking, and the raw readers all answer the column value, writers take plain values, and to_option lifts exactly one layer.
Four kinds of reader stay unwrapped, not three: a has_one declared required is the fourth, and the example nine lines below already showed it. Compromise 5 covers singular associations with nested attributes as well as encrypted attributes, matching the concern, and the register now says what it is a register of.
nz
force-pushed
the
nz/optional-readers-module
branch
from
September 8, 2026 21:51
b4ce1e8 to
e673919
Compare
A column reader is generated when the schema loads, so the assertion passed only when some earlier test had already touched Book, and the suite failed on roughly one seed in twenty.
The @example blocks under lib/errgonomic/rails never ran: the helper loaded lib/errgonomic alone, so the Rails constants an example needs were not defined. It now loads the integration, connects to an in-memory sqlite3 database and defines a model with one nullable column per type, which is what an example at an ActiveRecord seam has to have. Loading the integration makes None() answer nil?, and yard-doctest sends any expectation that answers nil? to assert_nil, which minitest resolves as nil == actual. That turned every `# => None()` in the core docs red, so an expected Option is compared by value instead.
A type cast has never heard of an Option, and each type failed its own way: a Some is truthy and is not one of ActiveModel's FALSE_VALUES, so a wrapped false stored true; the string casts called to_s and raised; the numeric casts worked through Option#presence, nudging on stderr every time; and the date casts handed the wrapper back uncast, leaking it into dirty tracking. Two seams, because they answer different criteria. Unwrapping in AttributeSet#write_from_user covers every writer, new, assign_attributes and update, and does it before the attribute object is built, which is what leaves raw values in changes, read_attribute_before_type_cast and attributes. Unwrapping in Type::Value#cast covers what never passes a writer: update_all, insert_all and upsert cast a hash of values on their way to a bind parameter, as does an attribute default. A prepend on Type::Value#serialize, the shape the report suggested, turns out to reach none of these: the bind path raises inside Value#cast before serialize is called.
The register's third compromise is the list of boundaries into ActiveRecord that unwrap an Option, and attribute assignment now joins quoting, predicate building and the singular association writers. Writers no longer ask the caller to unwrap, so the advice to reach for unwrap_or(nil) on the way in goes with it. The truthiness paragraph gains what unwrapping changes about a slip like `self.col = col || default`: it stops leaking a wrapper into the database and starts silently persisting nil.
An association writer looks like assignment to a typed field but is a setter, so it takes what the wrapped reader on another record hands back: Some(record) assigns the record, None() clears the association. Strictness stays on the reading side, where an absent value is the error to catch. Both macros reach SingularAssociation#writer, which neither BelongsToAssociation nor HasOneAssociation overrides, so one prepend covers them. Unwrapping there leaves the association's own type check in place, and a Some of the wrong class raises naming the class inside it.
Two of ActiveModel's type helpers touch the value on their way past and sit above Type::Value in a type's ancestors, so the prepend there never saw it. Helpers::Numeric asks the value for presence, which on an Option is the soft-deprecated unwrap and prints a nudge, and Helpers::Mutable serializes it, which an Option refuses. Every path that casts without passing an attribute writer hit one or the other: update_all, insert_all, upsert and an attribute default, for the json and numeric types. The helpers take a module of their own. Prepending the module already above Type::Value would be silently dropped, since a module already somewhere in a type's ancestors is not inserted into it a second time, which is what happened to Json. The register gains the bind path alongside assignment, and the README says where these seams live: on ActiveModel, so they reach every model rather than only the converted ones.
The register is closed at five, and unwrapping on assignment is the same boundary coercion as the SQL-side unwrap rather than a sixth compromise, so item 3 names both boundaries.
An attribute is a single typed field, so the value arriving at a writer or a cast is one value: an Option of it, or not. Reaching a level into an array to unwrap the Options inside was arbitrary next to hashes and deeper nestings going untouched, and it scanned every array that was ever deserialized. The array reach belongs to the predicate builder and the association writer, where a list of Options is a real shape a where clause takes, so unwrap_options keeps it and the attribute path takes unwrap_option.
Both reach the same writer, and both read the assigned record rather than only its key: a polymorphic belongs_to takes the foreign type from the record's class, and a has_one :through writes the join row from the record's own primary key. A polymorphic writer accepts any class, so it has no type check to reject a wrapper and instead fails reaching into it for a primary key.
Three test comments described the behaviour the tests were written against rather than the behaviour they now pin, which reads as history to anyone arriving later. Each says what its test asserts and why that type is the interesting one.
Every EachValidator fetches its attribute through
read_attribute_for_validation, so unwrapping there is the one seam
that puts the value in front of the standard validators. Until now
they saw the wrapper: inclusion and exclusion compared an Option
against a list of values and could never match, presence took any
Some as present including Some(""), and length and format asked an
Option for to_s and got a SerializeError.
Defining it on the concern rather than on ActiveRecord::Base scopes
the unwrap to models that have a wrapper to unwrap.
SomeValidator lifts what it is handed, so it keeps asking whether the
value is there now that it receives one, and answers the same
question on a model the concern never converted.
Item 5 was a list of readers left unwrapped. What unites it is a broader rule: where ActiveRecord's own machinery reads a value raw, it gets one. Validation reaches that by unwrapping at the seam, the other two by never being wrapped, so the item names the rule and then the three places it lands.
A converted model raised SerializeError from as_json, to_json and serializable_hash: every wrapped reader handed a payload the wrapper. Unwrap at read_attribute_for_serialization, the seam every attribute in a payload is fetched through, next to the validation seam that already does the same. Rails writes an absent value as null, and so does serde unless a field asks otherwise, so a None writes null and no declaration says so. The refusal on Option#as_json stands everywhere else: an Option handed to an arbitrary payload still raises.
The Rails integration section said what a reader returns and what a writer takes, and left validation to be inferred. It also promised a NoMethodError for some: on an override that hands back a plain value, which no longer happens now that the validator lifts what it is given.
Two paths into a payload skip the attribute seam. An include: reads its association off the record, so ActiveModel asked a Some for serializable_hash; a method named in methods: is read the same way, so a wrapped reader arrived wrapped. Wrap ActiveModel's yield rather than reimplement it: the include seam takes the record out of the Option and drops an absent one, where a nil association is already left out of the payload. A collection is never an Option and passes through.
absence, acceptance and comparison each reach the value a different way, and each was broken by the wrapper in its own way: absence read a Some as present whatever it held, acceptance found no wrapper equal to its literal, and comparison could not order one against a bound. A validator an application writes itself lands on the same seam, so it is covered too, and the changelog says what that changes for one written against the wrapper. Item 4 of the register described SomeValidator as presence-style, which is the one thing it is not.
serde makes skipping a None a per-field opt-in and writes null otherwise; Rails writes nil as null. Both agree on the default, so the declaration only has to buy the other behaviour. errgonomic_serialize_none :omit drops those keys, on a model or on a base class above it, and only:/except: scope it to named readers. The nearest declaration is the whole story for a class: it replaces what it inherits, so a reader a scoped declaration does not name keeps the default rather than the mode above it. Deleting from the payload rather than from the attribute list leaves the caller's own only: and except: in force.
The exclusion existed for one reason: encryption's length validator read the attribute raw and called to_s on it, so a wrapped encrypted attribute could not be saved. That validator now reads through the validation seam like every other, and the exclusion has nothing left to prevent. An encrypted column round-trips as an Option, a deterministic one is queryable, and downcase: normalizes on write. Register item 5 loses one of its two unwrapped readers, leaving the nested-attributes reader as the only one.
A model is now the one place an Option serializes, so the assertion that a bare Some still refuses belongs next to the nested ones rather than only in a doctest.
One place in ActiveRecord reads an association back through the public reader: the touch callback a belongs_to declared touch: true installs. It asks the record whether it is persisted and then to touch itself, so a Some has to answer both, and now a test says so. marked_for_destruction? was asked by the presence, absence and associated validators, which reach the association through read_attribute_for_validation and are handed the record itself. Its last caller is gone, so the delegation goes with it.
Rails convention puts a concern at the top of a model, but configuration reads as well above the include as below it, and the opt-out already works either way. A declaration on the concern could only be written under it. Move the writer and the inherited lookup next to errgonomic_optional_except, which ActiveRecord::Base gets before any model loads. The concern keeps what serialization asks: whether a named reader is one to leave out.
The query half of the test passes either way, because a query takes the value a caller supplies rather than the one a reader hands back. Reading the attribute is what says the exclusion is gone.
only: and except: cannot both be the set a declaration applies to, and a scoped :null asks for the default on the readers it names while leaving the rest at the default anyway. Both parsed and did nothing, which is the worst reading of a configuration line. Refuse them where they are written, with a message that names what to write instead.
find_by binds its values into a cached statement rather than through the predicate builder, so the value a caller passes reaches the column type with the wrapper still on. A string or integer column survived that because quoting unwraps further down; an encrypted one did not, because its type serializes through the underlying type and then calls to_s on the result. Serializing is the same boundary the cast already sits on, one step later, so it unwraps on the same terms and one layer deep.
The README's two consecutive declarations on one model read as alternatives, but as literal Ruby the second replaces the first. Split them across two models, which is what the sentence beside them means. Omission governs by reader name wherever the key came from, so a methods: entry naming a wrapped reader goes the way the reader does while a plain method answering nil is kept. Say so, and pin it.
Note.new.as_json['title'] answered nil whether the seam unwrapped a None or the key had gone missing. Save a record and read it back instead: a Some arrives as the plain string it stored, and the absent column is fetched by key, so a vanished key is a failure rather than another nil.
nz
enabled auto-merge
September 8, 2026 21:58
Unwrap an Option assigned to a singular association writer
Serialize a None as null by default, with omission as the opt-in
nz
disabled auto-merge
September 8, 2026 22:48
nz
enabled auto-merge
September 8, 2026 22:49
This was referenced Sep 8, 2026
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.
Closes #59.
ActiveRecordOptionalinstalled its wrapped readers withclass_evalon the model class, where a model's owndefof the same name lives, so one silently replaced the other depending on declaration order. Wrapped readers now live in a per-classModel::ErrgonomicOptionalReadersmodule, included into the model the way ActiveRecord includesGeneratedAttributeMethods. The module is created eagerly in the concern'sincludedblock and ininherited, so its position in the ancestor chain never depends on whenload_schema!fires.Composition direction is include, not prepend: a model's
defwins and itssuperreturns the Option. The wrapper lifts exactly one layer: a value beneath it that is already an Option passes through rather than nesting.Docs: a README Design section states the intersection-of-idioms purpose and points at the compromises register as the list of deviations; the override convention (
def isbn = super.map(&:strip)besidedef display_isbn), the reader-as-boundary rule with the raw surfaces named, truthiness ofNone, and the one-layer rule are documented; two register inaccuracies fixed.Verified locally: rubocop (no new offenses), yard doctest, rake test,
nix build .#errgonomic,nix flake check --all-systems.