Every create_table I have written for twenty years ends the same way, and I
typed it into a Hanami migration without a thought:
create_table :challenges do
primary_key :id
column :name, String, null: false
timestamps
end
That last line does not exist here.
undefined local variable or method 'timestamps' for an instance of
Sequel::Schema::CreateTableGenerator (NameError)
The stack trace points at
sequel-5.107.0/lib/sequel/database/schema_generator.rb, and that is the part
that tells you where you actually are. Migrations in Hanami are Sequel, and
timestamps was never Sequel’s. It is an ActiveRecord shorthand, and I had
been carrying it around as though it were a fact about databases.
I am building a CTF challenge tracker in Hanami 3 with my Rails habits deliberately left switched on, and this is the smallest friction the app has handed me so far. It is also the one that told me the most about how the framework thinks.
The fix that fixed nothing
Two lines, and the migration runs:
column :created_at, Time, null: false
column :updated_at, Time, null: false
Then I created a challenge through ChallengeRepo, which is the first thing
anybody does after a migration, and got this:
SQLite3::ConstraintException: NOT NULL constraint failed:
challenges.created_at (ROM::SQL::NotNullConstraintError)
The missing shorthand was not the surprise. The surprise was that replacing it did not finish the job.
In Rails, t.timestamps sells you two columns and a behaviour, in one word,
and I had never separated the two in my head. In Sequel I had bought the
columns. Nothing on earth was going to fill them.
Who fills them, then
I read the gems I already had installed rather than guess, which is becoming
the habit this app teaches most reliably. rom-core ships two plugins named
timestamps, one for schemas and one for commands, and the changeset path uses
neither. rom-changeset keeps its own registry of pipe steps, and it answers
the question in two methods:
def self.add_timestamps(data)
now = Time.now
Hash(created_at: now, updated_at: now).merge(data)
end
def self.touch(data)
Hash(updated_at: Time.now).merge(data)
end
That is the entire mechanism, and it is worth noticing the merge(data): the
step is a default rather than a mandate, so a value you pass in explicitly
wins over the one it just computed.
Which is what the challenge repo ended up saying:
def create(attributes)
challenges.changeset(:create, attributes).map(:add_timestamps).commit
end
Create and update are not the same verb
The other half of the story is one method down:
def mark_solved(id, solved_at)
challenges
.by_pk(id)
.changeset(:update, solved: true, solved_at: solved_at)
.map(:touch)
.commit
end
add_timestamps on the way in, touch on the way through. Rails draws that
same distinction, correctly, and in total silence. I have never once had to
know it was there, and if you had asked me last month whether a Rails update
rewrites created_at, I would have had to go and check rather than answer.
Here they are two different words, in a file I can read from top to bottom in one sitting.
Every repo has to remember
The cost shows up as soon as there is a second table. EventRepo, which lands
a few commits further on, carries the same .map(:add_timestamps) line in its
own create, because nothing was going to infer it for me. Every repo that
writes a row has to remember.
And when you forget, there is no convention check, no warning at boot, nothing at the point where you made the mistake. You get a NOT NULL violation at runtime from the database, which is the very last layer that could have caught it.
I have not landed on whether that is a good trade. The database is arguably the right place to be told, loudly, that a column declared mandatory has no value, and the call sits at the exact spot where the write happens, which is where I would want to read it. Against that: a rule you can forget is a rule somebody eventually forgets, and I have paid this cost twice, in an app with two tables. Twice is not thirty times.
Ten lines I had never read
The pattern is much bigger than two columns. add_reference does not exist
either, and add_foreign_key gives you the column and the constraint while
leaving the index as a decision you have to make out loud. The shorthand and
the behaviour are sold separately here, everywhere, and Hanami’s position is
that the second one belongs in view at the call site.
What stays with me is smaller and less comfortable than a verdict on ceremony. For twenty years I have shipped tables whose timestamps were filled by machinery I had never looked at, and I would have told you with a straight face that I understood how my application worked. It took two errors and about ten lines of somebody else’s Ruby for that to stop being true.
The state described here is tagged
02-four-objects,
the same checkpoint as the post on the four objects. That is deliberate: the
add_timestamps line was sitting in a listing in that post, and I walked past
it without a word.
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.