From 33be326e4c0c048eae39f1ea941ff8f1e99d2048 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 22:51:49 +0000 Subject: [PATCH] feat(rain): add Ignore() and support targetless OnConflict().DoNothing() This change adds dialect-native ignore semantics to InsertQuery, matching Drizzle ORM's behavior and ergonomics. - Added .Ignore() method to InsertQuery. - MySQL: renders "INSERT IGNORE INTO". - SQLite: renders "INSERT OR IGNORE INTO". - PostgreSQL: renders "ON CONFLICT DO NOTHING". - Supported targetless .OnConflict().DoNothing() for PostgreSQL and SQLite. - SQLite targetless .OnConflict().DoNothing() automatically uses the IGNORE prefix. - Preserved existing MySQL .OnConflict().DoNothing() behavior for compatibility. Verified with unit tests for all three dialects. Co-authored-by: cungminh2710 <8063319+cungminh2710@users.noreply.github.com> --- pkg/rain/query_insert.go | 58 ++++++++++++++++++- pkg/rain/query_insert_test.go | 102 ++++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+), 3 deletions(-) diff --git a/pkg/rain/query_insert.go b/pkg/rain/query_insert.go index fc834a1..7e64e91 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. @@ -177,6 +178,14 @@ func (q *InsertQuery) Values(rows ...map[schema.ColumnReference]any) *InsertQuer return q } +// Ignore configures the INSERT to ignore conflicts. +// MySQL renders "INSERT IGNORE", SQLite renders "INSERT OR IGNORE", +// and PostgreSQL renders "ON CONFLICT DO NOTHING". +func (q *InsertQuery) Ignore() *InsertQuery { + q.ignore = true + return q +} + // DefaultValues configures the INSERT to use default values for all columns. // PostgreSQL and SQLite render "DEFAULT VALUES", while MySQL renders "() VALUES ()". func (q *InsertQuery) DefaultValues() *InsertQuery { @@ -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 { @@ -648,7 +657,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 { @@ -766,6 +775,10 @@ func (q *InsertQuery) validateSources() error { func (q *InsertQuery) writeConflictClause(ctx *compileContext) error { if q.conflict == nil { + if q.ignore && q.dialect.Name() == "postgres" { + ctx.writeString(" ON CONFLICT DO NOTHING") + return nil + } return nil } if q.conflict.action == insertConflictActionNone { @@ -817,6 +830,16 @@ func (q *InsertQuery) writeConflictClause(ctx *compileContext) error { } if len(q.conflict.columns) == 0 && q.conflict.constraint == "" { + if q.conflict.action == insertConflictActionDoNothing { + if q.useIgnore() { + // Already handled by IGNORE prefix. + return nil + } + if name := q.dialect.Name(); name == "postgres" || name == "sqlite" { + ctx.writeString(" ON CONFLICT DO NOTHING") + return nil + } + } return errors.New("rain: conflict clause requires at least one target (columns or constraint)") } @@ -901,3 +924,32 @@ func mysqlConflictNoopColumn(table *schema.TableDef) (*schema.ColumnDef, error) // declared column only as a visible no-op target. return table.Columns[0], nil } + +func (q *InsertQuery) useIgnore() bool { + name := q.dialect.Name() + if name != "mysql" && name != "sqlite" { + return false + } + if q.ignore { + return true + } + // For SQLite, targetless OnConflict().DoNothing() also uses the IGNORE prefix. + if name == "sqlite" && q.conflict != nil && q.conflict.action == insertConflictActionDoNothing && len(q.conflict.columns) == 0 && q.conflict.constraint == "" { + return true + } + return false +} + +func (q *InsertQuery) writeInsertInto(ctx *compileContext) { + if q.useIgnore() { + switch ctx.dialect.Name() { + case "mysql": + ctx.writeString("INSERT IGNORE INTO ") + return + case "sqlite": + ctx.writeString("INSERT OR IGNORE INTO ") + return + } + } + ctx.writeString("INSERT INTO ") +} diff --git a/pkg/rain/query_insert_test.go b/pkg/rain/query_insert_test.go index 8e3554d..8ca7f8c 100644 --- a/pkg/rain/query_insert_test.go +++ b/pkg/rain/query_insert_test.go @@ -749,3 +749,105 @@ func TestInsertOnConflictMySQL(t *testing.T) { } }) } + +func TestInsertIgnore(t *testing.T) { + t.Parallel() + + users, _ := defineTables() + + t.Run("postgres ignore", func(t *testing.T) { + db, _ := rain.OpenDialect("postgres") + sqlText, args, 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) + } + if len(args) != 1 || args[0] != "alice@example.com" { + t.Fatalf("unexpected args: %#v", args) + } + }) + + t.Run("sqlite ignore", func(t *testing.T) { + db, _ := rain.OpenDialect("sqlite") + sqlText, args, 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) + } + if len(args) != 1 || args[0] != "alice@example.com" { + t.Fatalf("unexpected args: %#v", args) + } + }) + + t.Run("mysql ignore", func(t *testing.T) { + db, _ := rain.OpenDialect("mysql") + sqlText, args, 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) + } + if len(args) != 1 || args[0] != "alice@example.com" { + t.Fatalf("unexpected args: %#v", args) + } + }) + + t.Run("postgres 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) + } + }) + + t.Run("sqlite targetless on conflict do nothing uses prefix", 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) + } + }) +}