A :copyfrom query written as REPLACE INTO generated
LOAD DATA LOCAL INFILE '%s' INTO TABLE ..., dropping the REPLACE.
MySQL's default for LOAD DATA is to skip rows that collide on a
primary or unique key, so the upsert the query asked for silently
became a skip.
The parser already reports it (marino ast.InsertStmt.IsReplace), but
nothing read the bit. Carry it on ast.InsertStmt, derive it from the
raw statement in the compiler, add it to plugin.Query, and emit
REPLACE INTO TABLE from the template.
Fixes sqlc-dev#4339
Fixes #4339.
The bug
A MySQL :copyfrom query written as REPLACE INTO generated the same statement as an INSERT INTO one:
MySQL's default for LOAD DATA is to skip rows that collide on a primary or unique key (with a warning), so the upsert the query asked for silently became a skip. It generates without error, compiles, and only shows up as missing writes at runtime.
The fix
marino already reports it as ast.InsertStmt.IsReplace; nothing downstream read the bit. This carries it through:
The generated doc comment is also branched: "Errors and duplicate keys are treated as warnings" is false on a REPLACE method, where collisions overwrite rather than skip. The non-REPLACE branch is byte-identical to the current text, so no existing golden moved.
Scope notes
Testing
Coverage went into internal/endtoend/testdata/copyfrom/mysql rather than a new case directory, since its config is identical to the existing one and its InsertValues / InsertSingleValue queries act as controls that the flag does not leak onto sibling queries in the same package. Reverting either the converter line or the template line fails TestReplay/base/copyfrom/mysql; it also passes under the coreanalyzer context, so the compiler half is genuinely exercised.
Ran locally against live PostgreSQL and MySQL: go test --tags=examples -timeout 20m ./... (with sqlc-gen-json on $PATH, so the process-plugin goldens are not skipped), go build ./..., go vet ./..., gofmt, cd internal/endtoend/testdata && go build ./..., and buf lint. The two gen/codegen.json goldens pick up "insert_is_replace": false, which is the JSON codegen emitting defaults.
Before proposing the proto change I confirmed buf generate reproduces the committed codegen.pb.go byte-for-byte on unmodified input, so the regenerated file is only the new field.
One thing to flag honestly: InsertStmt.Format is included for consistency, but nothing exercises that path today (internal/x/expander is unwired, and TestFormat's MySQL fingerprint round-trips through Format on both sides, so it cannot fail on a dropped keyword). Without it, an AST formatter that renders REPLACE INTO as INSERT INTO would reintroduce this exact bug one layer up whenever the expander does get wired in. Glad to drop it if you would rather keep the diff to the codegen path.
I used an AI coding assistant while working on this. I reproduced the bug, ran the suite, and reviewed every line myself.