diff --git a/pkg/rain/query_insert.go b/pkg/rain/query_insert.go index 769fb41..03ed794 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. @@ -216,6 +217,13 @@ func (q *InsertQuery) Returning(exprs ...schema.Expression) *InsertQuery { return q } +// Ignore configures the INSERT to ignore conflicts. +// MySQL renders "INSERT IGNORE", SQLite renders "INSERT OR IGNORE". +func (q *InsertQuery) Ignore() *InsertQuery { + q.ignore = true + return q +} + // Prepare compiles and prepares the INSERT query. func (q *InsertQuery) Prepare(ctx context.Context) (*PreparedInsertQuery, error) { if q.runner == nil { @@ -287,7 +295,7 @@ func (q *InsertQuery) writeValuesSQL(ctx *compileContext) error { if err := q.validateSources(); err != nil { return err } - ctx.writeString("INSERT INTO ") + q.writeInsertInto(ctx) ctx.writeTableName(q.table) if ctx.dialect.Name() == "mysql" { ctx.writeString(" () VALUES ()") @@ -299,7 +307,7 @@ func (q *InsertQuery) writeValuesSQL(ctx *compileContext) error { return err } - ctx.writeString("INSERT INTO ") + q.writeInsertInto(ctx) ctx.writeTableName(q.table) if q.models != nil { @@ -521,6 +529,36 @@ func (q *InsertQuery) assignmentsFromModelAndSet() ([]assignment, error) { return assignments, nil } +func (q *InsertQuery) writeInsertInto(ctx *compileContext) { + ctx.writeString("INSERT") + if q.useIgnore(ctx) { + switch ctx.dialect.Name() { + case "mysql": + ctx.writeString(" IGNORE") + case "sqlite": + ctx.writeString(" OR IGNORE") + } + } + ctx.writeString(" INTO ") +} + +func (q *InsertQuery) useIgnore(ctx *compileContext) bool { + if q.ignore { + return true + } + if q.conflict != nil && q.conflict.action == insertConflictActionDoNothing { + // Drizzle behavior: .onConflictDoNothing() without target columns on + // MySQL or SQLite renders as IGNORE. + if ctx.dialect.Name() == "mysql" { + return true + } + if ctx.dialect.Name() == "sqlite" && len(q.conflict.columns) == 0 && q.conflict.constraint == "" { + return true + } + } + return false +} + func (q *InsertQuery) writeSingleRowSQL(ctx *compileContext) error { // Single row might be from a model and/or explicit .Set() values. // We use assignmentsFromModelAndSet which is already @@ -574,7 +612,7 @@ func (q *InsertQuery) writeSelectSQL(ctx *compileContext) error { selectQuery = selectQuery.withSQLiteInsertSelectConflictWhere() } - ctx.writeString("INSERT INTO ") + q.writeInsertInto(ctx) ctx.writeTableName(q.table) if len(q.columns) > 0 { @@ -710,6 +748,9 @@ func (q *InsertQuery) writeConflictClause(ctx *compileContext) error { return errors.New("rain: MySQL ON DUPLICATE KEY UPDATE does not support WHERE filters") } if q.conflict.action == insertConflictActionDoNothing { + if q.useIgnore(ctx) { + return nil + } noopColumn, err := mysqlConflictNoopColumn(q.table) if err != nil { return err @@ -743,6 +784,9 @@ func (q *InsertQuery) writeConflictClause(ctx *compileContext) error { } if len(q.conflict.columns) == 0 && q.conflict.constraint == "" { + if q.useIgnore(ctx) { + return nil + } return errors.New("rain: conflict clause requires at least one target (columns or constraint)") } diff --git a/pkg/rain/query_insert_test.go b/pkg/rain/query_insert_test.go index 8e3554d..390bfd7 100644 --- a/pkg/rain/query_insert_test.go +++ b/pkg/rain/query_insert_test.go @@ -269,6 +269,105 @@ func TestInsertOnConflictPostgres(t *testing.T) { }) } +func TestInsertIgnoreToSQL(t *testing.T) { + t.Parallel() + + users, _ := defineTables() + + type tc struct { + name string + dialect string + build func(*rain.DB) *rain.InsertQuery + wantSQL string + wantArgs []any + } + + cases := []tc{ + { + name: "mysql explicit ignore", + dialect: "mysql", + build: func(db *rain.DB) *rain.InsertQuery { + return db.Insert().Table(users).Set(users.Email, "alice@example.com").Ignore() + }, + wantSQL: "INSERT IGNORE INTO `users` (`email`) VALUES (?)", + wantArgs: []any{"alice@example.com"}, + }, + { + name: "mysql conflict do nothing renders as ignore", + dialect: "mysql", + build: func(db *rain.DB) *rain.InsertQuery { + return db.Insert().Table(users).Set(users.Email, "alice@example.com").OnConflict().DoNothing() + }, + wantSQL: "INSERT IGNORE INTO `users` (`email`) VALUES (?)", + wantArgs: []any{"alice@example.com"}, + }, + { + name: "sqlite explicit ignore", + dialect: "sqlite", + build: func(db *rain.DB) *rain.InsertQuery { + return db.Insert().Table(users).Set(users.Email, "alice@example.com").Ignore() + }, + wantSQL: `INSERT OR IGNORE INTO "users" ("email") VALUES (?)`, + wantArgs: []any{"alice@example.com"}, + }, + { + name: "sqlite conflict do nothing without target renders as ignore", + dialect: "sqlite", + build: func(db *rain.DB) *rain.InsertQuery { + return db.Insert().Table(users).Set(users.Email, "alice@example.com").OnConflict().DoNothing() + }, + wantSQL: `INSERT OR IGNORE INTO "users" ("email") VALUES (?)`, + wantArgs: []any{"alice@example.com"}, + }, + { + name: "sqlite conflict do nothing with target renders on conflict", + dialect: "sqlite", + build: func(db *rain.DB) *rain.InsertQuery { + return db.Insert().Table(users).Set(users.Email, "alice@example.com").OnConflict(users.Email).DoNothing() + }, + wantSQL: `INSERT INTO "users" ("email") VALUES (?) ON CONFLICT ("email") DO NOTHING`, + wantArgs: []any{"alice@example.com"}, + }, + { + name: "mysql insert select ignore", + dialect: "mysql", + build: func(db *rain.DB) *rain.InsertQuery { + subquery := db.Select().Table(users).Column(users.Email) + return db.Insert().Table(users).Columns(users.Email).Select(subquery).Ignore() + }, + wantSQL: "INSERT IGNORE INTO `users` (`email`) SELECT `users`.`email` FROM `users`", + }, + } + + for _, tt := range cases { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + db, err := rain.OpenDialect(tt.dialect) + if err != nil { + t.Fatalf("OpenDialect returned error: %v", err) + } + + sqlText, args, err := tt.build(db).ToSQL() + if err != nil { + t.Fatalf("ToSQL returned error: %v", err) + } + if sqlText != tt.wantSQL { + t.Fatalf("unexpected SQL:\nwant: %s\ngot: %s", tt.wantSQL, sqlText) + } + if len(args) != len(tt.wantArgs) { + t.Fatalf("unexpected arg count: want %d got %d (%#v)", len(tt.wantArgs), len(args), args) + } + for idx := range tt.wantArgs { + if args[idx] != tt.wantArgs[idx] { + t.Fatalf("unexpected arg[%d]: want %#v got %#v", idx, tt.wantArgs[idx], args[idx]) + } + } + }) + } +} + func TestInsertWithCTEToSQL(t *testing.T) { t.Parallel() @@ -680,7 +779,7 @@ func TestInsertOnConflictMySQL(t *testing.T) { } users, _ := defineTables() - t.Run("do nothing (no-op update)", func(t *testing.T) { + t.Run("do nothing (renders as IGNORE)", func(t *testing.T) { sqlText, args, err := db.Insert(). Table(users). Set(users.Email, "alice@example.com"). @@ -692,7 +791,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) }