From bcb43b4d52fa0b3e4f82a34da4b7a167a6d969b1 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:46:48 +0000 Subject: [PATCH] feat(rain): implement dialect-aware InsertQuery.Ignore() Adds the .Ignore() method to InsertQuery, providing a unified API for ignoring conflict errors across supported dialects: - MySQL: renders INSERT IGNORE - SQLite: renders INSERT OR IGNORE - PostgreSQL: renders ON CONFLICT DO NOTHING Also refactors targetless OnConflict().DoNothing() to use these native "ignore" syntaxes for MySQL and SQLite, and enables targetless ON CONFLICT DO NOTHING for PostgreSQL. Full test coverage added for all dialects and INSERT variants (values, models, and subqueries). Co-authored-by: cungminh2710 <8063319+cungminh2710@users.noreply.github.com> --- pkg/rain/query_insert.go | 56 ++++++++++-- pkg/rain/query_insert_test.go | 165 +++++++++++++++++++++++++++++++++- 2 files changed, 215 insertions(+), 6 deletions(-) diff --git a/pkg/rain/query_insert.go b/pkg/rain/query_insert.go index fc834a1..d8663a1 100644 --- a/pkg/rain/query_insert.go +++ b/pkg/rain/query_insert.go @@ -24,6 +24,7 @@ type InsertQuery struct { columns []schema.ColumnReference returning []schema.Expression conflict *insertConflictClause + ignore bool ctes []cteDefinition defaultValues bool @@ -210,6 +211,14 @@ func (b *InsertConflictBuilder) DoUpdateSet(columns ...schema.ColumnReference) * return &InsertConflictUpdateBuilder{query: b.query} } +// Ignore configures the INSERT to ignore conflict errors. +// PostgreSQL renders "ON CONFLICT DO NOTHING", while MySQL renders "INSERT IGNORE" +// and SQLite renders "INSERT OR IGNORE". +func (q *InsertQuery) Ignore() *InsertQuery { + q.ignore = true + return q +} + // Returning adds RETURNING expressions when supported by the dialect. func (q *InsertQuery) Returning(exprs ...schema.Expression) *InsertQuery { q.returning = append(q.returning, exprs...) @@ -287,7 +296,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 +308,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 { @@ -325,6 +334,33 @@ func (q *InsertQuery) writeValuesSQL(ctx *compileContext) error { return ctx.writeReturning(q.returning, q.returningClause()) } +func (q *InsertQuery) useIgnore() bool { + if q.ignore { + return true + } + if q.conflict == nil || q.conflict.action != insertConflictActionDoNothing { + return false + } + // For MySQL and SQLite, OnConflict().DoNothing() with no targets is + // equivalent to INSERT [OR] IGNORE. + return (q.dialect.Name() == "mysql" || q.dialect.Name() == "sqlite") && + len(q.conflict.columns) == 0 && q.conflict.constraint == "" +} + +func (q *InsertQuery) writeInsertInto(ctx *compileContext) { + if q.useIgnore() { + switch q.dialect.Name() { + case "mysql": + ctx.writeString("INSERT IGNORE INTO ") + return + case "sqlite": + ctx.writeString("INSERT OR IGNORE INTO ") + return + } + } + ctx.writeString("INSERT INTO ") +} + func (q *InsertQuery) writeModelsSQL(ctx *compileContext) error { value := reflect.ValueOf(q.models) for value.Kind() == reflect.Pointer { @@ -648,7 +684,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 { @@ -765,6 +801,11 @@ func (q *InsertQuery) validateSources() error { } func (q *InsertQuery) writeConflictClause(ctx *compileContext) error { + if q.ignore && q.dialect.Name() == "postgres" { + ctx.writeString(" ON CONFLICT DO NOTHING") + return nil + } + if q.conflict == nil { return nil } @@ -777,6 +818,9 @@ func (q *InsertQuery) writeConflictClause(ctx *compileContext) error { } if q.dialect.Name() == "mysql" { + if q.useIgnore() { + return nil + } if len(q.conflict.columns) > 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") } @@ -816,8 +860,8 @@ func (q *InsertQuery) writeConflictClause(ctx *compileContext) error { return nil } - if len(q.conflict.columns) == 0 && q.conflict.constraint == "" { - return errors.New("rain: conflict clause requires at least one target (columns or constraint)") + if q.dialect.Name() == "sqlite" && q.useIgnore() { + return nil } ctx.writeString(" ON CONFLICT") @@ -836,6 +880,8 @@ func (q *InsertQuery) writeConflictClause(ctx *compileContext) error { ctx.writeColumnName(col) } ctx.writeByte(')') + } else if q.conflict.action != insertConflictActionDoNothing { + return errors.New("rain: conflict clause requires at least one target (columns or constraint)") } if len(q.conflict.targetWhere) > 0 { diff --git a/pkg/rain/query_insert_test.go b/pkg/rain/query_insert_test.go index 8e3554d..c1556b5 100644 --- a/pkg/rain/query_insert_test.go +++ b/pkg/rain/query_insert_test.go @@ -269,6 +269,169 @@ func TestInsertOnConflictPostgres(t *testing.T) { }) } +func TestInsertIgnoreToSQL(t *testing.T) { + t.Parallel() + + users, _ := defineTables() + + t.Run("mysql insert ignore", func(t *testing.T) { + db, _ := rain.OpenDialect("mysql") + sqlText, _, err := db.Insert(). + Table(users). + Set(users.Email, "alice@example.com"). + Ignore(). + 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) + } + }) + + t.Run("sqlite insert or ignore", func(t *testing.T) { + db, _ := rain.OpenDialect("sqlite") + sqlText, _, err := db.Insert(). + Table(users). + Set(users.Email, "alice@example.com"). + Ignore(). + ToSQL() + if err != nil { + t.Fatalf("ToSQL returned error: %v", err) + } + + wantSQL := `INSERT OR IGNORE INTO "users" ("email") VALUES (?)` + if sqlText != wantSQL { + t.Fatalf("unexpected SQL:\nwant: %s\ngot: %s", wantSQL, sqlText) + } + }) + + t.Run("postgres ignore renders on conflict do nothing", func(t *testing.T) { + db, _ := rain.OpenDialect("postgres") + sqlText, _, err := db.Insert(). + Table(users). + Set(users.Email, "alice@example.com"). + Ignore(). + 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) + } + }) +} + +func TestInsertOnConflictDoNothingTargetless(t *testing.T) { + t.Parallel() + + users, _ := defineTables() + + t.Run("mysql renders insert ignore", func(t *testing.T) { + db, _ := rain.OpenDialect("mysql") + sqlText, _, 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 IGNORE INTO `users` (`email`) VALUES (?)" + if sqlText != wantSQL { + t.Fatalf("unexpected SQL:\nwant: %s\ngot: %s", wantSQL, sqlText) + } + }) + + t.Run("sqlite renders insert or ignore", func(t *testing.T) { + db, _ := rain.OpenDialect("sqlite") + sqlText, _, 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 OR IGNORE INTO "users" ("email") VALUES (?)` + if sqlText != wantSQL { + t.Fatalf("unexpected SQL:\nwant: %s\ngot: %s", wantSQL, sqlText) + } + }) + + t.Run("postgres renders targetless on conflict do nothing", func(t *testing.T) { + db, _ := rain.OpenDialect("postgres") + sqlText, _, 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) + } + }) +} + +func TestInsertSelectIgnoreToSQL(t *testing.T) { + t.Parallel() + + users, _ := defineTables() + + t.Run("mysql insert ignore select", func(t *testing.T) { + db, _ := rain.OpenDialect("mysql") + subquery := db.Select().Table(users).Column(users.Email) + + sqlText, _, err := db.Insert(). + Table(users). + Columns(users.Email). + Select(subquery). + Ignore(). + 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("postgres insert ignore select", func(t *testing.T) { + db, _ := rain.OpenDialect("postgres") + subquery := db.Select().Table(users).Column(users.Email) + + sqlText, _, err := db.Insert(). + Table(users). + Columns(users.Email). + Select(subquery). + Ignore(). + ToSQL() + if err != nil { + t.Fatalf("ToSQL returned error: %v", err) + } + + wantSQL := `INSERT INTO "users" ("email") SELECT "users"."email" FROM "users" ON CONFLICT DO NOTHING` + if sqlText != wantSQL { + t.Fatalf("unexpected SQL:\nwant: %s\ngot: %s", wantSQL, sqlText) + } + }) +} + func TestInsertWithCTEToSQL(t *testing.T) { t.Parallel() @@ -692,7 +855,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) }