Use Dapper and DbUp instead of EF Core (ADR-0020, ADR-0021) #14
Loading…
Reference in a new issue
No description provided.
Delete branch "docs/dapper-decision"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Records two decisions taken by the product owner, and follows the consequences. Documentation only.
.sqlscripts.Nothing is superseded. EF Core was never an ADR — it was asserted in
CLAUDE.mdand assumed by the data model, both freely edited. Three accepted records mention it in frozen bodies (0005, 0009, 0019), but their decisions stand; only the technology their consequence sections assumed has changed. ADR-0020 records the translation for each ("readDeleteBehavior.RestrictasON DELETE RESTRICT").ADR-0016 point 4 reopened — ICU collation wins over
citextIts deciding argument was EF migration ergonomics, and there are no migrations. Measured against
postgres:18.4-trixieand18-alpine:citextneedsCREATE EXTENSIONand rights a managed application role may not have.LIKEworks under a non-deterministic collation. Regex andtext_pattern_opsstill refuse — ADR-0006 excludes search, and the failure is an error, not a wrong answer.citextrules out the Alpine image. It does not — Alpine ships it in contrib.REINDEX+REFRESH VERSION.Other four points unchanged. UUIDv7's rationale strengthens under Dapper — registration inserts user, personal group and membership in one transaction, and the last two need an id the first has not returned.
Two DbUp behaviours that are load-bearing and silent
Both measured, both in ADR-0021's Decision rather than left to implementation:
WithTransactionPerScript()rolls it back.Needs your attention
Three tickets need retitling (43, 44, 45 all name EF Core). One acceptance criterion does not survive: #44's "Migration is reversible (
Downmethod implemented/tested)" — ADR-0021 argues against down scripts explicitly.Verification
dotnet build0/0,dotnet formatclean, links 311/39 anchors/0 problems.README.mdhad four EF references, all fixed;CONTRIBUTING.mdhad none.Verdict: changes needed
One blocking item, and it is a typo rather than an argument: a sentence in
docs/data-model.mdstops mid-clause. Everything else below is optional.I re-ran the measurements rather than taking them on trust. Every claim in the PR body that I could execute held, including the two that were hardest to believe.
Blocking
docs/data-model.md:231— truncated sentence in theusersconstraint table.The cell ends on "is". It reads as a dropped edit from the
citext→ collation rewrite (the old cell ended "because the column iscitext"). It is also the one line in the document a reader checks to find out why the unique constraint is case-insensitive, so it is worth more than the character count suggests. Something like "because the column's collation is non-deterministic" closes it.Verification of the four claims the PR stakes itself on
1. Supersession: I agree nothing is superseded. I read all three mentions in place. ADR-0005's is in Consequences, ADR-0019's is in Consequences, ADR-0009's is in Context (the hazard motivating the contracts boundary). None is in a Decision section, and none of the three decisions changes: hard delete, the cascade,
RESTRICTongroup_memberships.user_id, and the contracts boundary all read correctly today. ADR-0001 reserves partial supersession for a record "replacing part of an old decision", and no decision has been replaced — only the mechanism a consequence named. AddingPartially superseded by 0020would misreport all three, exactly as ADR-0020 argues. The obligation in ADR-0019 ("must be toldDeleteBehavior.Restrict") is the closest call, because it is phrased as an instruction; but the instruction it encodes — this relationship isRESTRICT, notCASCADE— survives verbatim, and the row in the foreign key table is unchanged.The residual cost is discoverability, and it is not fixable in this PR: a reader who lands on ADR-0019 directly gets no forward pointer, because ADR-0001 permits only status-field edits. The index prose covers it for anyone who arrives that way. Worth noting that ADR-0019 already flagged the mechanism that would fix this — an
Answered bymetadata field — and explicitly deferred it. That deferral now has a second customer.2. ICU collation over
citext— every claim reproduced, including the disproof. Againstpostgres:18.4-trixie:CREATE COLLATION case_insensitive (provider = icu, locale = 'und-u-ks-level2', deterministic = false)succeeds with no extension.ux_users_emailrejectedROB@example.comagainst a storedRob@Example.COMwithduplicate key value violates unique constraint.LIKEworks, and is case-insensitive under it — both anchored (LIKE 'rob@%') and mid-string (LIKE '%EXAMPLE%') matched.nondeterministic collations are not supported for regular expressions.text_pattern_opslikewise:nondeterministic collations are not supported for operator class "text_pattern_ops".pg_collation.collversionis153.128, the figure recorded.ANALYZE,EXPLAINgivesIndex Scan using ux_users_email, not a seq scan. This is the claim I most expected to fail, and it did not.On
citext:CREATE TABLE t (email citext)fails withtype "citext" does not existuntilCREATE EXTENSION citext— the quoted error, verbatim.The Alpine correction is right, and ADR-0016's original claim was wrong.
postgres:18-alpinelistscitext1.8 inpg_available_extensionsandCREATE EXTENSION citextsucceeds on it. Withdrawing that reason rather than quietly deleting it is the right call. The collation also works on Alpine — and, incidentally, itscollversionthere is153.136against trixie's153.128, so the two official variants of the same PostgreSQL 18.4 already carry different ICU. That does not create a mismatch in practice (each database creates its own collation against its own library), but it is a rather better illustration of the version cost ADR-0016 records than the hypothetical image bump.ADR-0016's enum aside also holds:
BEGIN; ALTER TYPE role ADD VALUE 'viewer'; SELECT 'viewer'::role;fails withunsafe use of new value "viewer" of enum type role, as quoted.3. Both DbUp claims reproduced.
dbup-postgresql7.0.1 (still the current version) againstpostgres:18.4-trixie, two scripts, the second creating a table twice:Default strategy: run failed at the second statement, and
betawas left behind in the database, unjournalled — journal contained only0001. The re-run then failed with42P07: relation "beta" already exists, which is the exact failure mode the ADR describes.WithTransactionPerScript(): same script, nothing left behind — onlyalphaandschemaversions— and0001stayed journalled.Journal key: the entry is
Rev14Probe.Scripts.0001-good.sql. Changing only<RootNamespace>toPlaceMark.Database, touching no script, re-discovered it asPlaceMark.Database.Scripts.0001-good.sqland re-ran it against a database that already had it, failing onalpha. The claim is understated if anything: the journal keeps the old key, so the failure is permanent until someone edits the journal.I also confirmed the ordering claim in ADR-0016/0021 end to end —
CREATE COLLATIONfollowed by a table declaringCOLLATE case_insensitivefollowed by an insert, in one transactional script, applies cleanly and matches case-insensitively afterwards.4. UUIDv7 — conclusion right, one clause overstated. "A key the application cannot know before it writes is a key it cannot use to write the rows that reference it" is sound, and "every insert would have to read its key back with a
RETURNINGclause" is exactly right. But the registration example goes one step further than the evidence: "each of the last two needs an identifier the first has not returned yet." A data-modifying CTE chains them in a single statement —— which I ran successfully against
uuidv7()column defaults on 18.4. So database generation is awkward here, not impossible: the real costs are the extra round trip per insert and the inability to build the graph before writing, both of which the preceding sentence already states correctly. ADR-0016 is still Proposed, so this is cheap to sharpen now and expensive later. Softening it to something like "each of the last two needs an identifier that only exists once the first has been written and read back" keeps the argument and drops the part that is not quite true.Non-blocking
docs/adr/README.md:88— "the initial migration is no longer blocked on either." The sweep caught every equivalent phrase indocs/data-model.md(four of them, all now "first schema script") but missed this one. Index prose is editable, and this is the one place a reader is told the schema is unblocked.ADR-0021 does not record what
WithTransactionPerScript()costs. It is put in the Decision as a blanket rule, correctly, and the record calls the builder call load-bearing — but transactional DDL is not universal. Measured on the same setup: a script containingCREATE INDEX CONCURRENTLYfails underWithTransactionPerScript()with25001: CREATE INDEX CONCURRENTLY cannot run inside a transaction block, and succeeds under the default strategy. ADR-0016's enum paragraph already handles a cousin of this ("has to be two scripts"), so the record clearly knows the family; the general statement is missing. At ADR-0007's scale nothing needs a concurrent index build and the failure is loud, so this is a sentence in Consequences rather than a change of decision — but it is the sort of thing whose absence costs an hour on the day someone hits it.docs/data-model.md:496asserts an equivalence it does not argue. "Five of the eight are the ones an ORM's conventions would have got wrong, and they are worth knowing as the five most likely to be got wrong by hand too." Plausible — those five are the ones deviating from an intuitive cascade — but with hand-written DDL any of the eight can simply be omitted, and the paragraph two above already gives the better instruction ("a count, not a search"). The ORM framing is doing rhetorical work here in a document that has just declared there are no conventions left to fight.ADR-0020 calls account deletion "three ordered statements". It is three ordered steps; step 3 is two statements and step 1 is a read. The linked section is authoritative and correct, so this is cosmetic.
Everything else checked
Statusfield in the file it links to (0016 correctly stillProposedin both).dotnet build: 0 warnings, 0 errors.dotnet format --verify-no-changes: clean.CONTRIBUTING.mdhad none, as stated.SERIALIZABLE,DeleteBehaviorand a Forgejo scope string, all external names.Dapper.DefaultTypeMap.MatchNamesWithUnderscoresis a settable static on the current Dapper (2.1.79), so the consequence about global start-up state is accurate;Guid.CreateVersion7()is present on the pinned SDK (10.0.110), as ADR-0016 says.The two documents are unusually well evidenced for documentation-only records — the DbUp transaction default in particular is the kind of thing normally discovered in production — and the decision to correct ADR-0016's Alpine claim in the open, at the cost of admitting the original reasoning was unchecked, is the right instinct. Fix the truncated line and this is mergeable.
Verdict: mergeable
Re-reviewed at
b8e9b81. My earlier verdict atb647dd5is superseded by this one. All five points are actioned, and the two that added new factual claims I re-measured rather than accepted.The blocking item is closed
docs/data-model.md:231now reads "case-insensitive, because that is what the column's collation makes it". Complete sentence, and it says the thing the cell exists to say.The four non-blocking points
Index prose. "first schema script". I re-ran the sweep against the new head: every remaining
migrationindocs/is either deliberate (ADR-0020 and ADR-0021 discussing EF migrations by name, ADR-0016 and the data model quoting the reason that was withdrawn) or in a frozen body using the word generically — ADR-0006:38 and ADR-0017:209, the two you named. Nothing left to sweep.CREATE INDEX CONCURRENTLY. Recorded in both the Decision ("it is not free either") and the Consequences, which is the right split — a reader who only skims the Decision now knows the strategy has a cost. The new claim in it is correct: aCREATE UNIQUE INDEX CONCURRENTLYthat fails on duplicate data leftix_dupbehind withpg_index.indisvalid = f, needing a manual drop, exactly as written. Recording the escape hatch's own cost is what makes the paragraph worth having — "just run it outside a transaction" is the advice someone would otherwise reach for at the worst moment.One refinement, entirely optional and not worth a revision on its own: "the strategy is chosen per run, not per script" is right about DbUp's API, and it is the reason the escape hatch works — but it also means the separate run must be filtered to that one script, or every other pending script in the same run loses its transaction too. The record's "for one script" implies this; spelling it out would close the gap for whoever reaches for it under pressure.
UUIDv7. The concession is better than what I asked for. Rather than softening the clause it states the wrong version explicitly, says why it is wrong, and moves the weight onto Client-side creation — which is the argument that was always doing the work and does not depend on what the database can chain. Naming the cost that survives (the statement's shape being dictated by where keys come from) keeps the alternative's real merit visible instead of pretending it has none.
Five of eight. The rewrite argues the shared cause — inferring delete behaviour from nullability — and the ORM equivalence now follows from it rather than being asserted alongside it, which is the right order. That also explains why EF Core would have got the same five wrong: same inference, performed by a tool. The arithmetic is right, and the correction matters: the four optional references are three
SET NULLplusgroups.personal_for_user_id, which is nullable (Null: yesin thegroupsfield table) and cascades. Four optional and four required, five in the at-risk set, eight rows. Catching that mid-rewrite is the kind of thing that would have read as authoritative and been wrong.ADR-0020's "three ordered statements" is now "three ordered steps — several statements, since step 3 alone deletes memberships and then the user". Matches the data model.
Re-verified at the new head
b647dd5touches seven Markdown files and nothing else, so the build and format results cannot have moved from the 0/0 and clean I measured on the previous revision; your re-run agrees.indisvalid = f: measured onpostgres:18.4-trixie.collversion,citexton Alpine, both DbUp behaviours and the journal key — is untouched by these edits.Nothing outstanding. Container and scratch directories from both passes are cleaned up.