I am building a CTF challenge tracker in Hanami 3 with my Rails habits deliberately left switched on, to find out where they stop working. This time the habit was associations, and the lesson came from a failure that did not change.
A challenge belongs to the event it was set at. In Rails that sentence is the implementation.
The declaration is familiar
Hanami has belongs_to, and it lives in the relation’s schema:
schema :challenges, infer: true do
associations do
belongs_to :event
end
end
Reassuring. So I wrote the spec I would have written in Rails, expecting to be done in a minute:
challenge = repo.all.first
expect(challenge.event.name).to eq("Insomni'hack")
The failure that did not move
ROM::Struct::MissingAttribute:
undefined method 'event' for an instance of CtfTracker::Structs::Challenge (attribute not loaded?)
Fair enough, I thought, I had not declared the association yet. I added the
associations block, ran the same spec again, and got the same failure. Not a
similar one. The same text, down to the question mark.
That is the whole article, so it is worth being precise about what it means.
In Rails, belongs_to does two jobs at once: it tells the framework how the
tables relate, and it installs an accessor that goes and fetches the row when
you touch it. Those two jobs feel like one because you never see them apart.
Here you only get the first. The declaration teaches ROM how challenges and
events relate, which is what makes it possible to ask for them together. It
installs nothing on the struct, because the struct is built from whatever the
query actually loaded, and the query loaded columns from one table.
The error message is unusually honest about it. (attribute not loaded?) is
the framework guessing what you are about to say next.
Asking, in the repo
Reaching the event is a different method, and it says so:
def all_with_event
challenges.combine(:event).to_a
end
combine is the part with no Rails equivalent in spirit. It is not
includes, which is an optimisation you add to code that already worked. It
is the only way the data arrives at all. Forget it and nothing loads slowly:
nothing loads.
Two queries, whatever N is
This is where I stopped being annoyed and started paying attention. I counted
the statements the SQL logger emits for all_with_event, first with three
challenges in the table and then with ten. Both times, two.
One query for the challenges, one for their events, then ROM stitches the results together in memory. The count does not move with the number of rows, and it cannot, because there is no per-row code path that could fire a query.
I have spent a genuinely unreasonable share of my career on N+1 problems. Not
finding them, which is easy once you look at a log, but on the fact that they
come back. Somebody renders a partial in a loop, somebody adds a field to a
serialiser, somebody removes an includes while cleaning up and nothing
fails. The query count is a property of code that nobody is looking at.
Here that class of mistake needs a different kind of effort. The lazy path does not exist to be taken by accident, so the failure mode is a loud exception in development rather than a slow page in production. I pinned that direction with a test too, because it is a promise I want to keep:
challenge = repo.all.first
expect { challenge.event }.to raise_error(ROM::Struct::MissingAttribute)
What it costs
A repo method per shape of need. all and all_with_event today, and I can
see all_with_event_and_writeups coming for the page that shows both. In
Rails there is one Challenge.all and each screen decorates it with whatever
it happens to require.
That is more code, and I am not going to pretend the extra method is a pleasure to write. What I get for it is that every screen’s data requirements are written down in a place I can read, in a file whose whole job is to list what this application asks of that table. When somebody later wonders why a page is slow, the answer is in one file rather than distributed across the templates it renders.
Ask me again when this app has thirty tables and the repo has fifteen methods. For now, the trade reads well: the framework took away the gesture that is convenient nine times and catastrophic the tenth.
The state described here is tagged
03-explicit-associations.
Comments, questions, or just a reaction?
Send an email to ~bounga/bounga.org-discuss@lists.sr.ht. It is public and archived, so other readers can follow along and answer too.