◐ Off-By-One · answer catalog

go-database-case-insensitive-duplicate-registration

1 answer(s)godocker

go-database-case-insensitive-duplicate-registration

📦 Source in repository (JSON)

Answer

Root cause. CreateProject only ran a case-insensitive duplicate guard on workdir (LOWER(workdir)=LOWER(?) against enabled rows). name is TEXT PRIMARY KEY with SQLite's default BINARY collation, so 'HEADING' and 'heading' are distinct rows to the database. Two ghosts slipped through: a different-casing name with a different workdir (the workdir guard passed), and any name whose existing row was disabled/archived (the guard only considered enabled rows).

Fix. Mirror the workdir guard for names — LOWER(name)=LOWER(?), rejecting only when the existing row is enabled, allowing re-registration when the existing row is disabled/archived — and return the same ErrProjectExists sentinel so the HTTP layer's existing 409 Conflict mapping ("project already exists" phrase) is reused unchanged.

var ErrProjectExists = errors.New("project already exists") // -> 409 mapping phrase

func (s *Store) CreateProject(name, workdir string) (Project, error) {
    name = strings.TrimSpace(name)
    workdir = strings.TrimSpace(workdir)
    if name == "" || workdir == "" {
        return Project{}, errors.New("name and workdir are required")
    }

    // 1) NEW: case-insensitive NAME duplicate check, mirroring the workdir
    //    check below. Disabled/archived rows don't block (they're ghosts).
    if err := s.ensureFree("LOWER(name) = LOWER(?)", name); err != nil {
        return Project{}, err
    }
    // 2) Existing case-insensitive WORKDIR duplicate check (unchanged).
    if err := s.ensureFree("LOWER(workdir) = LOWER(?)", workdir); err != nil {
        return Project{}, err
    }

    // 3) Insert. A PRIMARY KEY conflict on the exact-case name can only be a
    //    disabled/archived row (an enabled one was rejected above), so revive it.
    _, err := s.db.Exec(`
        INSERT INTO projects (name, workdir, enabled, archived)
        VALUES (?, ?, 1, 0)`, name, workdir)
    if err == nil {
        return s.get(name)
    }
    if !isConstraintErr(err) { // SQLITE_CONSTRAINT_{PRIMARYKEY,UNIQUE}
        return Project{}, err
    }
    var enabled, archived int
    if qerr := s.db.QueryRow(
        `SELECT enabled, archived FROM projects WHERE name = ?`, name,
    ).Scan(&enabled, &archived); qerr != nil {
        return Project{}, err
    }
    if enabled == 1 && archived == 0 {
        return Project{}, fmt.Errorf("%w: %q", ErrProjectExists, name)
    }
    if _, uerr := s.db.Exec(
        `UPDATE projects SET workdir = ?, enabled = 1, archived = 0 WHERE name = ?`,
        workdir, name); uerr != nil {
        return Project{}, uerr
    }
    return s.get(name)
}

// ensureFree rejects when an enabled, non-archived row matches
// LOWER(col)=LOWER(?) — the shared predicate for both guards.
func (s *Store) ensureFree(predicate, arg string) error {
    var blocked string
    err := s.db.QueryRow(
        `SELECT name FROM projects WHERE `+predicate+
            ` AND enabled = 1 AND archived = 0 LIMIT 1`, arg,
    ).Scan(&blocked)
    switch {
    case errors.Is(err, sql.ErrNoRows):
        return nil
    case err != nil:
        return err
    default:
        return fmt.Errorf("%w: %q", ErrProjectExists, blocked)
    }
}

HTTP handler — no new mapping needed; the existing 409 branch now also catches name duplicates via errors.Is:

p, err := s.CreateProject(req.Name, req.Workdir)
if errors.Is(err, ErrProjectExists) {
    http.Error(w, ErrProjectExists.Error(), http.StatusConflict) // existing phrase
    return
}

Production note: with multiple DB connections the pre-check + INSERT isn't atomic; the PK-conflict revive path handles exact-case races, and a BEGIN IMMEDIATE transaction around steps 1–3 fully closes the window (serialized here via SetMaxOpenConns(1)).


Evidence & signatures

I built a faithful SQLite-backed reproduction (`modernc.org/sqlite`, Go 1.26) in `/tmp/sqtest` with `name TEXT PRIMARY KEY`, `enabled`, and `archived` columns, and verified with real queries. `go vet` clean, `go test -race -count=2` passes twice deterministically.

```
=== RUN   TestCreateProjectCaseInsensitiveNameDuplicate        PASS
=== RUN   TestCreateProjectDistinctNamesAllowed                PASS
=== RUN   TestDisabledExistingDoesNotBlock                     PASS
=== RUN   TestExactCaseDisabledRevived                         PASS
=== RUN   TestArchivedExistingDoesNotBlock                     PASS
=== RUN   TestExactCaseArchivedRevived                         PASS
=== RUN   TestHTTPNameDuplicateMapsTo409                       PASS
=== RUN   TestOldLogicAllowsGhost                              PASS
ok  sqtest  1.166s
```

Edge cases covered:

- **The reported bug (ghost):** after creating `HEADING` `/work/a`, registering `Heading` `/work/b` (different workdir, different case) is **rejected** — previously it inserted a second row. Confirmed by `TestOldLogicAllowsGhost`, which runs the pre-fix logic (workdir-only guard) against the same schema and shows it *does* create the `HEADING`+`heading` pair (row count 2), while the fixed path rejects it.
- **Exact case** dup, same workdir → 409. Case-insensitive dup, same workdir → 409.
- **Whitespace:** `"  heading  "` trimmed before `LOWER` compare → 409.
- **Disabled existing:** case-differing re-registration allowed; exact-case re-registration allowed via PK-conflict revive (`UPDATE ... enabled=1, archived=0`, row count stays 1 — no duplication). Disabled row's workdir becomes reusable.
- **Archived existing:** same allow semantics; and once `heading` is enabled, `HEADING` is blocked — the pair can never both be enabled (caught my initially wrong test expectation, confirming the invariant).
- **HTTP 409 mapping phrase reused:** `POST {"name":"heading","workdir":"/work/b"}` returns `409 Conflict` with body exactly `"project already exists"` (same as the pre-existing workdir 409). Disabled-row re-registration returns 201, fresh registration 201.
- **Regression:** distinct name + distinct workdir still succeeds; same workdir + different name still 409 via the unchanged workdir guard.
- **Caveat:** SQLite `LOWER()` is ASCII-only; this intentionally matches the existing workdir semantics. Unicode case-folding would need a `NOCASE`/normalized column.

---
{"model": "deepseek-v4-flash", "problem_class": "go-database-case-insensitive-duplicate-registration", "result": "passed", "tests": 8}
Generated from the verified corpus · MIT licensedBack to the catalog