Skip to content

Commit 2fdf5bf

Browse files
committed
mysql: keep REPLACE INTO when generating a :copyfrom LOAD DATA
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 #4339
1 parent 8a7cddf commit 2fdf5bf

15 files changed

Lines changed: 162 additions & 58 deletions

File tree

‎docs/howto/insert.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,10 @@ and use SHOW WARNINGS to check for any problems and roll back if necessary.
208208
209209
Check the [error handling](https://dev.mysql.com/doc/refman/8.0/en/load-data.html#load-data-error-handling) documentation for more information.
210210
211+
Writing the query as `REPLACE INTO` generates `LOAD DATA ... REPLACE INTO TABLE`,
212+
which overwrites rows that collide on a primary or unique key instead of skipping
213+
them.
214+
211215
```sql
212216
CREATE TABLE foo (a text, b integer, c DATETIME, d DATE);
213217

‎internal/cmd/shim.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ func pluginQueries(r *compiler.Result) []*plugin.Query {
161161
Params: params,
162162
Filename: q.Metadata.Filename,
163163
InsertIntoTable: iit,
164+
InsertIsReplace: q.InsertIsReplace(),
164165
})
165166
}
166167
return out

‎internal/codegen/golang/query.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,8 @@ type Query struct {
279279
Arg QueryValue
280280
// Used for :copyfrom
281281
Table *plugin.Identifier
282+
// Used for :copyfrom, true when the query was written as REPLACE INTO
283+
InsertIsReplace bool
282284
}
283285

284286
func (q Query) hasRetType() bool {

‎internal/codegen/golang/result.go‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -218,14 +218,15 @@ func buildQueries(req *plugin.GenerateRequest, options *opts.Options, enums []En
218218
}
219219

220220
gq := Query{
221-
Cmd: query.Cmd,
222-
ConstantName: constantName,
223-
FieldName: sdk.LowerTitle(query.Name) + "Stmt",
224-
MethodName: query.Name,
225-
SourceName: query.Filename,
226-
SQL: query.Text,
227-
Comments: comments,
228-
Table: query.InsertIntoTable,
221+
Cmd: query.Cmd,
222+
ConstantName: constantName,
223+
FieldName: sdk.LowerTitle(query.Name) + "Stmt",
224+
MethodName: query.Name,
225+
SourceName: query.Filename,
226+
SQL: query.Text,
227+
Comments: comments,
228+
Table: query.InsertIntoTable,
229+
InsertIsReplace: query.InsertIsReplace,
229230
}
230231
sqlpkg := parseDriver(options.SqlPackage)
231232

‎internal/codegen/golang/templates/go-sql-driver-mysql/copyfromCopy.tmpl‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,16 @@ func convertRowsFor{{.MethodName}}(w *io.PipeWriter, {{.Arg.SlicePair}}) {
2525
{{end -}}
2626
// {{.MethodName}} uses MySQL's LOAD DATA LOCAL INFILE and is not atomic.
2727
//
28+
{{if .InsertIsReplace -}}
29+
// Errors are treated as warnings and insertion will continue, even without an
30+
// error for some cases. Rows that collide on a primary or unique key replace
31+
// the existing row. Use this in a transaction and use SHOW WARNINGS to check
32+
// for any problems and roll back if you want to.
33+
{{else -}}
2834
// Errors and duplicate keys are treated as warnings and insertion will
2935
// continue, even without an error for some cases. Use this in a transaction
3036
// and use SHOW WARNINGS to check for any problems and roll back if you want to.
37+
{{end -}}
3138
//
3239
// Check the documentation for more information:
3340
// https://dev.mysql.com/doc/refman/8.0/en/load-data.html#load-data-error-handling
@@ -40,7 +47,7 @@ func (q *Queries) {{.MethodName}}(ctx context.Context{{if $.EmitMethodsWithDBArg
4047
go convertRowsFor{{.MethodName}}(pw, {{.Arg.Name}})
4148
// The string interpolation is necessary because LOAD DATA INFILE requires
4249
// the file name to be given as a literal string.
43-
result, err := {{if (not $.EmitMethodsWithDBArgument)}}q.{{end}}db.ExecContext(ctx, fmt.Sprintf("LOAD DATA LOCAL INFILE '%s' INTO TABLE {{.TableIdentifierForMySQL}} %s ({{range $index, $name := .Arg.ColumnNames}}{{if gt $index 0}}, {{end}}{{$name}}{{end}})", "Reader::" + rh, mysqltsv.Escaping))
50+
result, err := {{if (not $.EmitMethodsWithDBArgument)}}q.{{end}}db.ExecContext(ctx, fmt.Sprintf("LOAD DATA LOCAL INFILE '%s' {{if .InsertIsReplace}}REPLACE {{end}}INTO TABLE {{.TableIdentifierForMySQL}} %s ({{range $index, $name := .Arg.ColumnNames}}{{if gt $index 0}}, {{end}}{{$name}}{{end}})", "Reader::" + rh, mysqltsv.Escaping))
4451
if err != nil {
4552
return 0, err
4653
}

‎internal/compiler/query.go‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,17 @@ type Query struct {
5555
RawStmt *ast.RawStmt
5656
}
5757

58+
// InsertIsReplace reports whether the query was written as REPLACE INTO. MySQL
59+
// renders a :copyfrom for such a query as LOAD DATA ... REPLACE INTO TABLE, so
60+
// that rows colliding on a key overwrite instead of being skipped.
61+
func (q *Query) InsertIsReplace() bool {
62+
if q.RawStmt == nil {
63+
return false
64+
}
65+
ins, ok := q.RawStmt.Stmt.(*ast.InsertStmt)
66+
return ok && ins.IsReplace
67+
}
68+
5869
type Parameter struct {
5970
Number int
6071
Column *Column

‎internal/endtoend/testdata/codegen_json/gen/codegen.json‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66389,7 +66389,8 @@
6638966389
],
6639066390
"comments": [],
6639166391
"filename": "query.sql",
66392-
"insert_into_table": null
66392+
"insert_into_table": null,
66393+
"insert_is_replace": false
6639366394
},
6639466395
{
6639566396
"text": "SELECT id, name, bio FROM authors\nORDER BY name",
@@ -66478,7 +66479,8 @@
6647866479
"params": [],
6647966480
"comments": [],
6648066481
"filename": "query.sql",
66481-
"insert_into_table": null
66482+
"insert_into_table": null,
66483+
"insert_is_replace": false
6648266484
},
6648366485
{
6648466486
"text": "INSERT INTO authors (\n name, bio\n) VALUES (\n $1, $2\n)\nRETURNING id, name, bio",
@@ -66630,7 +66632,8 @@
6663066632
"catalog": "",
6663166633
"schema": "",
6663266634
"name": "authors"
66633-
}
66635+
},
66636+
"insert_is_replace": false
6663466637
},
6663566638
{
6663666639
"text": "DELETE FROM authors\nWHERE id = $1",
@@ -66670,7 +66673,8 @@
6667066673
],
6667166674
"comments": [],
6667266675
"filename": "query.sql",
66673-
"insert_into_table": null
66676+
"insert_into_table": null,
66677+
"insert_is_replace": false
6667466678
}
6667566679
],
6667666680
"sqlc_version": "v1.31.1",

‎internal/endtoend/testdata/copyfrom/mysql/go/copyfrom.go‎

Lines changed: 38 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎internal/endtoend/testdata/copyfrom/mysql/go/query.sql.go‎

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎internal/endtoend/testdata/copyfrom/mysql/query.sql‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,6 @@ INSERT INTO foo (a, b, c, d) VALUES (?, ?, ?, ?);
33

44
-- name: InsertSingleValue :copyfrom
55
INSERT INTO foo (a) VALUES (?);
6+
7+
-- name: ReplaceValues :copyfrom
8+
REPLACE INTO foo (a, b, c, d) VALUES (?, ?, ?, ?);

0 commit comments

Comments
 (0)