Skip to content

Wrap optional readers in a per-class module so model overrides compose - #64

Merged
nz merged 38 commits into
mainfrom
nz/optional-readers-module
Sep 8, 2026
Merged

nz merged 38 commits into
mainfrom
nz/optional-readers-module

Conversation

@nz

@nz nz commented Sep 8, 2026

Copy link
Copy Markdown
Member

Closes #59.

ActiveRecordOptional installed its wrapped readers with class_eval on the model class, where a model's own def of the same name lives, so one silently replaced the other depending on declaration order. Wrapped readers now live in a per-class Model::ErrgonomicOptionalReaders module, included into the model the way ActiveRecord includes GeneratedAttributeMethods. The module is created eagerly in the concern's included block and in inherited, so its position in the ancestor chain never depends on when load_schema! fires.

Composition direction is include, not prepend: a model's def wins and its super returns 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) beside def display_isbn), the reader-as-boundary rule with the raw surfaces named, truthiness of None, 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.

nz added 9 commits September 8, 2026 14:30
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 added 19 commits September 8, 2026 16:57
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.
nz added 7 commits September 8, 2026 16:57
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
nz enabled auto-merge September 8, 2026 21:58
nz added 2 commits September 8, 2026 17:47
Unwrap an Option assigned to a singular association writer
Serialize a None as null by default, with omission as the opt-in
@nz
nz disabled auto-merge September 8, 2026 22:48
@nz
nz enabled auto-merge September 8, 2026 22:49

@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 f13b4d6 into main Sep 8, 2026
1 check passed

@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.

🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ActiveRecordOptional: class_eval reader wrapping silently destroys (or is destroyed by) model-defined methods — wrap via a module

2 participants