Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 55 additions & 3 deletions pkg/rain/query_insert.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 ()")
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)")
}

Expand Down Expand Up @@ -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 ")
}
102 changes: 102 additions & 0 deletions pkg/rain/query_insert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}