Skip to content

Wrap where the include goes, whenever it lands - #52

Merged
nz merged 4 commits into
mainfrom
nz/wrap-late-optional-belongs-to
Aug 12, 2026
Merged

Wrap where the include goes, whenever it lands#52
nz merged 4 commits into
mainfrom
nz/wrap-late-optional-belongs-to

Conversation

@nz

@nz nz commented Aug 12, 2026

Copy link
Copy Markdown
Member

First of a stack. Where the include goes now decides how far it reaches, so this PR carries the hooks, the schema seam, and what a base-class include needed. Branches after it are stacked and I will open them one at a time as this merges: nz/optional-has-onenz/nested-attributes-unwrapnz/cheap-recursion-guard. nz/idempotent-to-option is independent and can be reviewed whenever.

Wrapping followed the include's position, and only reached one class

included do read the reflections and the columns at include time, which made two things true and neither was intended.

The include's position inside a model mattered. Put it where Rails convention puts a concern — at the top, above the associations — and the result was a half-converted model: nullable columns wrapped, associations not, nothing said so.

class Book < ApplicationRecord
  include Errgonomic::Rails::ActiveRecordOptional   # the conventional spelling

  belongs_to :author, optional: true                # silently not wrapped
end

A class body needed a database. Reading column_names at include time meant a model that included the concern raised ActiveRecord::DatabaseConnectionError on load where the same model without it loaded fine. Any boot that loads models without a reachable database — an asset build, an image build, a schema check — failed on the include.

Both come from the same fix

encrypts was already hooked, because an attribute declared after the include has to be caught when it arrives. belongs_to now works the same way, and the columns are wrapped from load_schema!, which is where ActiveRecord defines its own attribute methods. Wrapping is no longer tied to the include's position, and no longer needs a database while classes load.

Once wrapping lives in per-model hooks, the include reaches wherever it is put, so an application can convert every model at once by putting it on its own base class:

class ApplicationRecord < ActiveRecord::Base
  primary_abstract_class
  include Errgonomic::Rails::ActiveRecordOptional
end

That is opt-in or opt-out as placement rather than configuration — no mode to set, no install step to order against the rest of boot. An application's own base class is the useful target: engine and gem models (ActiveStorage::Blob, PaperTrail::Version) descend straight from ActiveRecord::Base, so they stay out by construction rather than by a namespace list this gem would have to maintain.

Three things had to change to make that include work, each with a test that fails without it:

  • An abstract class has no table. The walk up the chain asked one for its columns and raised before any model loaded.
  • Exclusions were snapshotted at include time. A base-class include leaves a model no "before" to declare anything in, so exclusions are read when a reader is about to be wrapped, and errgonomic_optional_except also takes back a reader already wrapped.
  • A model needs an exit that does not point at an include of its ownerrgonomic_optional_off, alongside the existing per-attribute errgonomic_optional_except.

Two knock-ons worth reviewing

encrypts now records an exclusion for a schema that has not loaded yet, rather than only reclaiming a reader that already exists.

A subclass reaches the schema seam on its own, so whichever of parent and child is touched first would wrap the shared columns first. A child that got there first wrapped its parent's readers a second time: Some(Some(x)) for a present value, collapsing back to None for an absent one because None#nil? is true — half of it silent. Wrapping walks the chain from the top down instead. Reproducible on the isolated case as Novel#isbn re-entered itself.

errgonomic_optionals also had to start loading the schema, since it is how a conversion gets checked and it answered with the associations alone until something else happened to touch the model.

Testing

rake — 24 tests, 63 assertions, and 118 doctests, all passing. Rubocop reports the same 7 pre-existing offenses as main; the size cops stop applying to test/, where splitting a case to satisfy one hides the behaviour it names.

The concern only wrapped the associations a class had already declared, so
the conventional placement of a concern — at the top of the model, above
its associations — silently produced a half-converted model: nullable
columns wrapped, associations not, and no signal that it had happened.

Hooking belongs_to the way encrypts is already hooked makes placement
irrelevant. Wrapping now runs through one path, which also lets
errgonomic_optional_except name an association and not just an attribute.
h3h
h3h previously approved these changes Aug 12, 2026
nz added 3 commits August 12, 2026 15:03
Reading column_names in the included block made a model's class body
require a live database connection: a class that includes the concern
raises DatabaseConnectionError on load, where the same class without it
loads fine. Any boot that loads models without a reachable database — an
asset build, an image build, a schema check — fails on the include.

ActiveRecord already has a seam for this. It defines attribute methods the
first time a model needs its schema, and load_schema! is where that
happens, so wrapping from there restores ordinary lazy loading. Two
consequences follow: encrypts must record its exclusion for a schema that
has not arrived yet rather than only reclaiming a reader, and a subclass
now reaches the seam a second time, so wrapping has to see the readers an
ancestor already wrapped or it would nest them.
Wrapping the columns at schema load left errgonomic_optionals answering with
the associations alone until something else happened to touch the model,
which is exactly backwards: the set is how a conversion gets checked, and it
was empty right after the include. Asking now loads the schema, and the
wrapping itself reads the raw list so it does not ask the schema to load
while it is loading.
Wrapping happens in per-model hooks now — the association macros and the
schema seam — so where the include goes decides how far it reaches. On a
model, that model converts; on an application's base class, every model
below it does, and no model mentions errgonomic again. Converting one model
or the whole application is placement rather than a setting.

An application's own base class is the useful place for it. Engine and gem
models descend straight from ActiveRecord::Base, and their code reads their
attributes knowing nothing about an Option, so they stay out of it by
construction rather than by a list of namespaces this gem would maintain.

Three things had to change for that include to work. An abstract class has
no table, and the walk up the chain asked one for its columns, which raised
before any model loaded. Exclusions were snapshotted at include time, but a
base class include leaves a model no "before" to declare anything in, so
they are read when a reader is about to be wrapped, and errgonomic_optional_except
also takes back a reader already wrapped. And a model needs a way out that
does not point at an include of its own: errgonomic_optional_off.

Rubocop stops applying the size cops to test/, where splitting a case to
satisfy one hides the behaviour it was written to name.
@nz nz changed the title Wrap an optional belongs_to declared after the include Wrap where the include goes, whenever it lands Aug 12, 2026
@nz
nz merged commit 1e1e4fa into main Aug 12, 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

Development

Successfully merging this pull request may close these issues.

2 participants