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.