diff --git a/pkg/rain/query_insert.go b/pkg/rain/query_insert.go index 769fb41..9e7e754 100644 --- a/pkg/rain/query_insert.go +++ b/pkg/rain/query_insert.go @@ -26,6 +26,7 @@ type InsertQuery struct { conflict *insertConflictClause ctes []cteDefinition defaultValues bool + ignore bool // OPTIMIZATION: Internal buffers to avoid heap allocations for common // query shapes while keeping the struct size reasonable. @@ -42,7 +43,7 @@ const ( ) type insertConflictClause struct { - columns []schema.ColumnReference + targets []schema.Expression constraint string targetWhere []schema.Predicate action insertConflictAction @@ -184,9 +185,15 @@ func (q *InsertQuery) DefaultValues() *InsertQuery { return q } +// Ignore configures the INSERT to use IGNORE for MySQL. +func (q *InsertQuery) Ignore() *InsertQuery { + q.ignore = true + return q +} + // OnConflict starts an upsert clause for PostgreSQL and SQLite dialects. -func (q *InsertQuery) OnConflict(columns ...schema.ColumnReference) *InsertConflictBuilder { - q.conflict = &insertConflictClause{columns: columns} +func (q *InsertQuery) OnConflict(targets ...schema.Expression) *InsertConflictBuilder { + q.conflict = &insertConflictClause{targets: targets} return &InsertConflictBuilder{query: q} } @@ -261,6 +268,10 @@ func (q *InsertQuery) compile() (compiledQuery, error) { ctx := newCompileContext(q.dialect) defer releaseCompileContext(ctx) + if q.dialect.Name() == "mysql" && q.conflict != nil && q.conflict.action == insertConflictActionDoNothing { + q.ignore = true + } + if q.selectQuery != nil { if err := q.writeSelectSQL(ctx); err != nil { return compiledQuery{}, err @@ -287,7 +298,11 @@ func (q *InsertQuery) writeValuesSQL(ctx *compileContext) error { if err := q.validateSources(); err != nil { return err } - ctx.writeString("INSERT INTO ") + ctx.writeString("INSERT ") + if q.ignore && ctx.dialect.Name() == "mysql" { + ctx.writeString("IGNORE ") + } + ctx.writeString("INTO ") ctx.writeTableName(q.table) if ctx.dialect.Name() == "mysql" { ctx.writeString(" () VALUES ()") @@ -299,7 +314,11 @@ func (q *InsertQuery) writeValuesSQL(ctx *compileContext) error { return err } - ctx.writeString("INSERT INTO ") + ctx.writeString("INSERT ") + if q.ignore && ctx.dialect.Name() == "mysql" { + ctx.writeString("IGNORE ") + } + ctx.writeString("INTO ") ctx.writeTableName(q.table) if q.models != nil { @@ -574,7 +593,11 @@ func (q *InsertQuery) writeSelectSQL(ctx *compileContext) error { selectQuery = selectQuery.withSQLiteInsertSelectConflictWhere() } - ctx.writeString("INSERT INTO ") + ctx.writeString("INSERT ") + if q.ignore && ctx.dialect.Name() == "mysql" { + ctx.writeString("IGNORE ") + } + ctx.writeString("INTO ") ctx.writeTableName(q.table) if len(q.columns) > 0 { @@ -703,7 +726,11 @@ func (q *InsertQuery) writeConflictClause(ctx *compileContext) error { } if q.dialect.Name() == "mysql" { - if len(q.conflict.columns) > 0 || q.conflict.constraint != "" || len(q.conflict.targetWhere) > 0 { + if q.conflict.action == insertConflictActionDoNothing { + return nil + } + + if len(q.conflict.targets) > 0 || q.conflict.constraint != "" || len(q.conflict.targetWhere) > 0 { return errors.New("rain: MySQL ON DUPLICATE KEY UPDATE does not support conflict targets (columns, constraints, or WHERE); call OnConflict() without modifiers") } if len(q.conflict.updateWhere) > 0 { @@ -742,7 +769,7 @@ func (q *InsertQuery) writeConflictClause(ctx *compileContext) error { return nil } - if len(q.conflict.columns) == 0 && q.conflict.constraint == "" { + if len(q.conflict.targets) == 0 && q.conflict.constraint == "" && q.conflict.action != insertConflictActionDoNothing { return errors.New("rain: conflict clause requires at least one target (columns or constraint)") } @@ -750,16 +777,22 @@ func (q *InsertQuery) writeConflictClause(ctx *compileContext) error { if q.conflict.constraint != "" { ctx.writeString(" ON CONSTRAINT ") ctx.writeQuotedIdentifier(q.conflict.constraint) - } else if len(q.conflict.columns) > 0 { + } else if len(q.conflict.targets) > 0 { ctx.writeString(" (") - for idx, col := range q.conflict.columns { - if err := validateColumnBelongsToTable(q.table, col.ColumnDef()); err != nil { - return err - } + for idx, target := range q.conflict.targets { if idx > 0 { ctx.writeString(", ") } - ctx.writeColumnName(col) + if col, ok := target.(schema.ColumnReference); ok { + if err := validateColumnBelongsToTable(q.table, col.ColumnDef()); err != nil { + return err + } + ctx.writeColumnName(col) + } else { + if err := ctx.writeExpression(target); err != nil { + return err + } + } } ctx.writeByte(')') } diff --git a/pkg/rain/query_insert_test.go b/pkg/rain/query_insert_test.go index 8e3554d..dd7ea4a 100644 --- a/pkg/rain/query_insert_test.go +++ b/pkg/rain/query_insert_test.go @@ -181,6 +181,43 @@ func TestInsertOnConflictPostgres(t *testing.T) { } }) + t.Run("do nothing without targets", func(t *testing.T) { + sqlText, args, err := db.Insert(). + Table(users). + Set(users.Email, "alice@example.com"). + OnConflict(). + DoNothing(). + ToSQL() + if err != nil { + t.Fatalf("ToSQL returned error: %v", err) + } + + wantSQL := `INSERT INTO "users" ("email") VALUES ($1) ON CONFLICT DO NOTHING` + if sqlText != wantSQL { + t.Fatalf("unexpected SQL:\nwant: %s\ngot: %s", wantSQL, sqlText) + } + if len(args) != 1 { + t.Fatalf("unexpected args: %#v", args) + } + }) + + t.Run("functional index target", func(t *testing.T) { + sqlText, _, err := db.Insert(). + Table(users). + Set(users.Email, "alice@example.com"). + OnConflict(schema.Raw("(lower(email))")). + DoNothing(). + ToSQL() + if err != nil { + t.Fatalf("ToSQL returned error: %v", err) + } + + wantSQL := `INSERT INTO "users" ("email") VALUES ($1) ON CONFLICT ((lower(email))) DO NOTHING` + if sqlText != wantSQL { + t.Fatalf("unexpected SQL:\nwant: %s\ngot: %s", wantSQL, sqlText) + } + }) + t.Run("do update set", func(t *testing.T) { sqlText, args, err := db.Insert(). Table(users). @@ -680,7 +717,7 @@ func TestInsertOnConflictMySQL(t *testing.T) { } users, _ := defineTables() - t.Run("do nothing (no-op update)", func(t *testing.T) { + t.Run("do nothing (mapped to ignore)", func(t *testing.T) { sqlText, args, err := db.Insert(). Table(users). Set(users.Email, "alice@example.com"). @@ -692,7 +729,7 @@ func TestInsertOnConflictMySQL(t *testing.T) { t.Fatalf("insert on conflict mysql do nothing ToSQL returned error: %v", err) } - wantSQL := "INSERT INTO `users` (`email`, `name`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `id` = `id`" + wantSQL := "INSERT IGNORE INTO `users` (`email`, `name`) VALUES (?, ?)" if sqlText != wantSQL { t.Fatalf("unexpected mysql do nothing SQL:\nwant: %s\ngot: %s", wantSQL, sqlText) } @@ -701,19 +738,6 @@ func TestInsertOnConflictMySQL(t *testing.T) { } }) - t.Run("target columns are rejected for do nothing", func(t *testing.T) { - _, _, err := db.Insert(). - Table(users). - Set(users.Email, "alice@example.com"). - Set(users.Name, "Alice"). - OnConflict(users.Email). - DoNothing(). - ToSQL() - if err == nil || !strings.Contains(err.Error(), "does not support conflict targets") { - t.Fatalf("expected mysql conflict target error, got %v", err) - } - }) - t.Run("target columns are rejected for do update set", func(t *testing.T) { _, _, err := db.Insert(). Table(users). @@ -748,4 +772,57 @@ func TestInsertOnConflictMySQL(t *testing.T) { t.Fatalf("unexpected mysql do update args: %#v", args) } }) + + t.Run("ignore", func(t *testing.T) { + sqlText, args, err := db.Insert(). + Ignore(). + Table(users). + Set(users.Email, "alice@example.com"). + ToSQL() + if err != nil { + t.Fatalf("ToSQL returned error: %v", err) + } + + wantSQL := "INSERT IGNORE INTO `users` (`email`) VALUES (?)" + if sqlText != wantSQL { + t.Fatalf("unexpected SQL:\nwant: %s\ngot: %s", wantSQL, sqlText) + } + if len(args) != 1 { + t.Fatalf("unexpected args: %#v", args) + } + }) + + t.Run("ignore with select", func(t *testing.T) { + subquery := db.Select().Table(users).Column(users.Email) + sqlText, _, err := db.Insert(). + Ignore(). + Table(users). + Columns(users.Email). + Select(subquery). + ToSQL() + if err != nil { + t.Fatalf("ToSQL returned error: %v", err) + } + + wantSQL := "INSERT IGNORE INTO `users` (`email`) SELECT `users`.`email` FROM `users`" + if sqlText != wantSQL { + t.Fatalf("unexpected SQL:\nwant: %s\ngot: %s", wantSQL, sqlText) + } + }) + + t.Run("ignore with default values", func(t *testing.T) { + sqlText, _, err := db.Insert(). + Ignore(). + Table(users). + DefaultValues(). + ToSQL() + if err != nil { + t.Fatalf("ToSQL returned error: %v", err) + } + + wantSQL := "INSERT IGNORE INTO `users` () VALUES ()" + if sqlText != wantSQL { + t.Fatalf("unexpected SQL:\nwant: %s\ngot: %s", wantSQL, sqlText) + } + }) }