Dev.to · 5 min read

Adding a UUID Primary Key to My Rust Framework Forced Me to Actually Test It

Adding a UUID Primary Key to My Rust Framework Forced Me to Actually Test It

I maintain Runique, a Django-inspired web framework for Rust built on Axum, SeaORM and Tera. One of its core DSL pieces is a Pk type alias: declare pk: id => Pk on a model and the framework picks the concrete column type for you — i32 by default, i64 behind a big-pk feature flag, or a real Uuid behind a pk-uuid flag. SeaORM already supports UUID primary keys natively, so wiring Pk to resolve to Uuid looked, on paper, like a small job: one more arm in a match statement, one more Cargo feature. What I actually wanted out of it wasn't the UUID plumbing itself — it was everything downstream. UUID PKs matter for real reasons (no sequential IDs leaking record counts through a URL, no central sequence to coordinate if you ever split the database), but a framework this size also accumulates code that quietly assumes "the primary key is some integer." Swapping in a PK type that very much isn't an integer is a good way to find every place that assumption got baked in without anyone deciding to bake it in. That's exactly what happened. Just not the way I expected. Surprise #1: you can't just cargo test Once Pk could resolve to three different concrete types depending on the active feature, one cargo test run stopped being enough. Some test code assumes i32, some assumes Uuid — mix them in the same compilation unit and it's a straight compile error, not a flaky test, not a runtime surprise. A wall. The only way to actually exercise all three setups was three separate runs: cargo test --features "all-databases" cargo test --no-default-features --features "all-databases,big-pk" cargo test --no-default-features --features "all-databases,pk-uuid" Sounds like a minor CI annoyance. It wasn't — it's what actually surfaced the real bugs. CI started failing a couple days after I added the feature, and my first instinct was a race condition, something timing-sensitive in the test harness. It wasn't a race. The default run was quietly exercising a completely different code path than pk-uuid, and nobody had ever pointed that path at a real database to see what came out the other end. What the migration generator had been getting away with Once I ran the generator's output against real Postgres, MariaDB, and SQLite in Docker — not just checking it compiled, actually executing the SQL — three bugs fell out. All pre-existing. All invisible under the default feature set. USING landing in the wrong spot. An enum/type-transition migration chained a nullable spec (.not_null() / .null()) together with a .using(...) cast. sea-query appends USING at the very end of the whole ALTER TABLE statement no matter which clause it conceptually belongs to, so the output read: ALTER TABLE t ALTER COLUMN x TYPE new_type, ALTER COLUMN x SET NOT NULL USING expr USING stuck after SET NOT NULL instead of right after TYPE — Postgres rejects that. An enum/type transition never actually changes nullability anyway, so the fix was just to stop setting the nullable spec on that particular call. Postgres folding a name I never quoted. Enum renames go through Postgres's native ALTER TYPE ... RENAME VALUE. The CREATE TYPE ... AS ENUM that first creates the type is never quoted, so Postgres lowercases it at creation. But the Type::alter() builder used for renames always quotes, which preserves case — so it went looking for "ChangelogCategory" when the stored name was changelogcategory. Same object, two spellings, depending on which code path touched it. Fixed by lowercasing the name at that one call site — CREATE/DROP TYPE needed nothing, since neither is ever quoted either. SQLite panicking, not rejecting. Not a syntax error — sea-query's SQLite backend refuses to render any modify_column, period, because SQLite's real ALTER TABLE only does ADD/RENAME/DROP COLUMN. Every enum-transition and nullable/type-change migration blew up the moment it hit that backend. Fix: skip modify_column on SQLite at runtime. Harmless for enum transitions (SQLite has no native enum, it's already plain text there); for the general case the constraint genuinely isn't applied on that engine, so the generated file now says so with an explicit warning. None of these three have anything to do with UUIDs specifically — they'd have bitten regardless of PK type. What surfaced them now was that testing the UUID work properly meant, for the first time, actually running the generator's SQL against three real engines instead of trusting a green cargo test. Green tests, live bug That last part turned into its own small crisis. Some of the broken migrations had passing tests — green, no failures — while still emitting invalid SQL. The tests checked that the generator ran and produced something, not that the something was SQL a real database would accept. Perfectly valid Rust can wrap completely broken SQL, and a test that only asserts "returned Ok" can't tell the difference. That's the actual takeaway here, more than any one of the three bugs: a passing test that never runs the generated SQL against a real engine isn't verifying what it looks like it's verifying. Since then, the rule I hold myself to — this suite and going forward — is to check what a piece of code actually produces, not just whether it compiles or doesn't panic. Not quite done I figured that closed out the UUID work. It didn't — a few weeks later, writing docs for Pk, I ran into a second bug in the same feature, in a completely different file, with nothing to do with the migration generator or Docker at all. That one's next. Runique is open source: https://github.com/seb-alliot/runique .Claude supported me in maintaining the test suite for this pass — generating tests and verifying the actual results against the database across all three engines.

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More AI & Machine Learning News