Skip to content

OnConflictDoNothing().One() reports ErrNotFound on the exact path an idempotent insert exists to serve #146

Description

@jryannel

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

  1. 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.
  2. 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.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions