Porting an INSERT … ON CONFLICT DO NOTHING and hit the trap that sqlb-queries already documents. Filing it anyway, because the skill catching it is an argument that the API should.
The shape
-- what it was
INSERT INTO billing_subscriptions (org_id) VALUES ($1)
ON CONFLICT (org_id) DO NOTHING;
The function exists to be idempotent: something needs the row to exist, and does not care whether it already did.
_, err := sqlb.InsertRows(&row).Only("org_id").
OnConflictDoNothing("org_id").
One(ctx, db) // <-- wrong, and reads as right
One is the natural reach for a single-row insert, and every other single-row insert in the port uses it. Here it is a bug: DO NOTHING means a conflicting insert returns no row, One maps no row to ErrNotFound, and the function reports failure on precisely the case it was written for. The first call succeeds and every subsequent one errors.
Exec is correct — empty slice, nil error — and that is what the code now does. No complaint about the semantics; the complaint is that nothing says so at the call site.
Why it is worth an API change rather than only a doc
The failure has an unusually bad profile:
- It inverts with state. Tests that insert into a clean database pass. The failure needs the row to already exist, which is the second call, which is the case a test for idempotency would cover and a test for "it inserts" would not.
- The error is plausible.
ErrNotFound from an insert is strange enough to notice if you are looking, but it arrives through the same if err != nil { return fmt.Errorf(...) } as everything else, and "not found" from a function whose job is to make something exist reads as a real database problem rather than as a category error.
One's doc does not mention conflict clauses, and OnConflictDoNothing's does not mention terminals. Each is locally reasonable; the interaction is what bites, and neither doc is where you are looking when you write the other.
What would help, roughly in order
- Refuse the combination.
One after OnConflictDoNothing is, as far as I can construct, never what anyone means — "give me exactly one row, and also do not produce a row on conflict" is a contradiction. Failing it at build time with a message naming Exec would have cost me nothing and saved the lookup.
- A distinct sentinel. If refusing is too strong,
ErrConflictSkipped (or similar) instead of ErrNotFound would at least make the error say what happened. Callers who genuinely want "did it insert" get a clean branch, and the misuse stops looking like a missing row.
- Cross-reference the docs. Cheapest, and worth doing regardless: one sentence on
OnConflictDoNothing saying that a skipped insert returns no row and therefore that One will report ErrNotFound.
Note on the skill
sqlb-queries lists this among its four traps, and it is the first of the four this port has hit — which reads as evidence the trap list was chosen from real failures rather than imagined ones. It did not help me here, because the skill is in sqlb's repo rather than in the consumer's, and what loads in this repo is the generated sqlb-schema (see #142, #143). Not a request to move it — just a data point that a well-chosen trap list only pays off where the code is being written.
Porting an
INSERT … ON CONFLICT DO NOTHINGand hit the trap thatsqlb-queriesalready documents. Filing it anyway, because the skill catching it is an argument that the API should.The shape
The function exists to be idempotent: something needs the row to exist, and does not care whether it already did.
Oneis the natural reach for a single-row insert, and every other single-row insert in the port uses it. Here it is a bug: DO NOTHING means a conflicting insert returns no row,Onemaps no row toErrNotFound, and the function reports failure on precisely the case it was written for. The first call succeeds and every subsequent one errors.Execis correct — empty slice, nil error — and that is what the code now does. No complaint about the semantics; the complaint is that nothing says so at the call site.Why it is worth an API change rather than only a doc
The failure has an unusually bad profile:
ErrNotFoundfrom an insert is strange enough to notice if you are looking, but it arrives through the sameif err != nil { return fmt.Errorf(...) }as everything else, and "not found" from a function whose job is to make something exist reads as a real database problem rather than as a category error.One's doc does not mention conflict clauses, andOnConflictDoNothing's does not mention terminals. Each is locally reasonable; the interaction is what bites, and neither doc is where you are looking when you write the other.What would help, roughly in order
OneafterOnConflictDoNothingis, as far as I can construct, never what anyone means — "give me exactly one row, and also do not produce a row on conflict" is a contradiction. Failing it at build time with a message namingExecwould have cost me nothing and saved the lookup.ErrConflictSkipped(or similar) instead ofErrNotFoundwould at least make the error say what happened. Callers who genuinely want "did it insert" get a clean branch, and the misuse stops looking like a missing row.OnConflictDoNothingsaying that a skipped insert returns no row and therefore thatOnewill reportErrNotFound.Note on the skill
sqlb-querieslists this among its four traps, and it is the first of the four this port has hit — which reads as evidence the trap list was chosen from real failures rather than imagined ones. It did not help me here, because the skill is in sqlb's repo rather than in the consumer's, and what loads in this repo is the generatedsqlb-schema(see #142, #143). Not a request to move it — just a data point that a well-chosen trap list only pays off where the code is being written.