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
106 changes: 67 additions & 39 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
useIgnore bool

// OPTIMIZATION: Internal buffers to avoid heap allocations for common
// query shapes while keeping the struct size reasonable.
Expand Down Expand Up @@ -184,6 +185,15 @@ func (q *InsertQuery) DefaultValues() *InsertQuery {
return q
}

// Ignore configures the INSERT to ignore conflict errors.
// MySQL renders "INSERT IGNORE", SQLite renders "INSERT OR IGNORE".
// PostgreSQL does not support this and will return an error during compilation;
// use .OnConflict().DoNothing() instead for PostgreSQL.
func (q *InsertQuery) Ignore() *InsertQuery {
q.useIgnore = 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}
Expand Down Expand Up @@ -287,8 +297,9 @@ func (q *InsertQuery) writeValuesSQL(ctx *compileContext) error {
if err := q.validateSources(); err != nil {
return err
}
ctx.writeString("INSERT INTO ")
ctx.writeTableName(q.table)
if err := q.writeInsertInto(ctx); err != nil {
return err
}
if ctx.dialect.Name() == "mysql" {
ctx.writeString(" () VALUES ()")
} else {
Expand All @@ -299,8 +310,9 @@ func (q *InsertQuery) writeValuesSQL(ctx *compileContext) error {
return err
}

ctx.writeString("INSERT INTO ")
ctx.writeTableName(q.table)
if err := q.writeInsertInto(ctx); err != nil {
return err
}

if q.models != nil {
if err := q.writeModelsSQL(ctx); err != nil {
Expand Down Expand Up @@ -648,8 +660,9 @@ func (q *InsertQuery) writeSelectSQL(ctx *compileContext) error {
selectQuery = selectQuery.withSQLiteInsertSelectConflictWhere()
}

ctx.writeString("INSERT INTO ")
ctx.writeTableName(q.table)
if err := q.writeInsertInto(ctx); err != nil {
return err
}

if len(q.columns) > 0 {
ctx.writeString(" (")
Expand Down Expand Up @@ -729,6 +742,43 @@ func (q *InsertQuery) Scan(ctx context.Context, dest any) error {
return err
}

func (q *InsertQuery) writeInsertInto(ctx *compileContext) error {
dialectName := ctx.dialect.Name()

renderIgnore := q.useIgnore
if q.conflict != nil && q.conflict.action == insertConflictActionDoNothing {
if dialectName == "mysql" {
renderIgnore = true
} else if dialectName == "sqlite" && len(q.conflict.columns) == 0 && q.conflict.constraint == "" {
renderIgnore = true
}
}

if renderIgnore {
switch dialectName {
case "mysql":
ctx.writeString("INSERT IGNORE INTO ")
case "sqlite":
ctx.writeString("INSERT OR IGNORE INTO ")
case "postgres":
if q.useIgnore {
return errors.New("rain: .Ignore() is not supported for PostgreSQL; use .OnConflict().DoNothing() instead")
}
ctx.writeString("INSERT INTO ")
default:
if q.useIgnore {
return fmt.Errorf("rain: .Ignore() is not implemented for %s dialect", dialectName)
}
ctx.writeString("INSERT INTO ")
}
} else {
ctx.writeString("INSERT INTO ")
}
Comment on lines +745 to +776

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Ignore() + OnConflict().DoUpdateSet() combination is not validated on MySQL

When useIgnore is true and the conflict action is insertConflictActionDoUpdateSet, writeInsertInto writes INSERT IGNORE INTO, then writeConflictClause appends ON DUPLICATE KEY UPDATE .... The resulting SQL — INSERT IGNORE INTO … ON DUPLICATE KEY UPDATE col = ? — is syntactically valid in MySQL but semantically contradictory: the ON DUPLICATE KEY UPDATE clause specifically handles duplicate-key conflicts, while INSERT IGNORE silently discards all other errors. A user who calls .Ignore().OnConflict().DoUpdateSet(col) likely expects an error, not silently contradictory SQL. Consider adding a validation that useIgnore and DoUpdateSet are mutually exclusive for MySQL.

Prompt To Fix With AI
This is a comment left during a code review.
Path: pkg/rain/query_insert.go
Line: 745-776

Comment:
**`Ignore()` + `OnConflict().DoUpdateSet()` combination is not validated on MySQL**

When `useIgnore` is `true` and the conflict action is `insertConflictActionDoUpdateSet`, `writeInsertInto` writes `INSERT IGNORE INTO`, then `writeConflictClause` appends `ON DUPLICATE KEY UPDATE ...`. The resulting SQL — `INSERT IGNORE INTO … ON DUPLICATE KEY UPDATE col = ?` — is syntactically valid in MySQL but semantically contradictory: the `ON DUPLICATE KEY UPDATE` clause specifically handles duplicate-key conflicts, while `INSERT IGNORE` silently discards all other errors. A user who calls `.Ignore().OnConflict().DoUpdateSet(col)` likely expects an error, not silently contradictory SQL. Consider adding a validation that `useIgnore` and `DoUpdateSet` are mutually exclusive for MySQL.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Codex


ctx.writeTableName(q.table)
return nil
}

func (q *InsertQuery) validateSources() error {
if q.table == nil {
return errors.New("rain: insert query requires a table")
Expand Down Expand Up @@ -784,14 +834,7 @@ 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 {
noopColumn, err := mysqlConflictNoopColumn(q.table)
if err != nil {
return err
}
ctx.writeString(" ON DUPLICATE KEY UPDATE ")
ctx.writeQuotedIdentifier(noopColumn.Name)
ctx.writeString(" = ")
ctx.writeQuotedIdentifier(noopColumn.Name)
// Handled by INSERT IGNORE in writeInsertInto
return nil
}
Comment on lines 836 to 839

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 MySQL INSERT IGNORE silences more errors than the old no-op idiom

The old ON DUPLICATE KEY UPDATE id = id approach surfaces every error except a duplicate-key violation. INSERT IGNORE converts all MySQL errors — data truncation, out-of-range values, FK violations, generated-column failures, etc. — into warnings and silently discards the affected row. Any existing caller of OnConflict().DoNothing() on MySQL that relied on non-duplicate-key errors being propagated will now silently swallow those errors without any indication that something went wrong. The PR description does not call out this behavioral difference, and there are no tests that verify non-duplicate-key errors still surface.

Prompt To Fix With AI
This is a comment left during a code review.
Path: pkg/rain/query_insert.go
Line: 836-839

Comment:
**MySQL `INSERT IGNORE` silences more errors than the old no-op idiom**

The old `ON DUPLICATE KEY UPDATE id = id` approach surfaces every error _except_ a duplicate-key violation. `INSERT IGNORE` converts **all** MySQL errors — data truncation, out-of-range values, FK violations, generated-column failures, etc. — into warnings and silently discards the affected row. Any existing caller of `OnConflict().DoNothing()` on MySQL that relied on non-duplicate-key errors being propagated will now silently swallow those errors without any indication that something went wrong. The PR description does not call out this behavioral difference, and there are no tests that verify non-duplicate-key errors still surface.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Codex

if q.conflict.action == insertConflictActionDoUpdateSet {
Expand All @@ -817,6 +860,16 @@ func (q *InsertQuery) writeConflictClause(ctx *compileContext) error {
}

if len(q.conflict.columns) == 0 && q.conflict.constraint == "" {
if q.conflict.action == insertConflictActionDoNothing {
if q.dialect.Name() == "postgres" {
ctx.writeString(" ON CONFLICT DO NOTHING")
return nil
}
if q.dialect.Name() == "sqlite" {
// Handled by INSERT OR IGNORE in writeInsertInto
return nil
}
}
return errors.New("rain: conflict clause requires at least one target (columns or constraint)")
}

Expand Down Expand Up @@ -876,28 +929,3 @@ func (q *InsertQuery) writeConflictClause(ctx *compileContext) error {

return nil
}

func mysqlConflictNoopColumn(table *schema.TableDef) (*schema.ColumnDef, error) {
if table == nil {
return nil, errors.New("rain: insert query requires a table")
}

if primaryKey, err := tablePrimaryKeyConstraint(table); err != nil {
return nil, err
} else if primaryKey != nil && len(primaryKey.Columns) > 0 {
return primaryKey.Columns[0], nil
}

if primaryKeys := primaryKeyColumns(table); len(primaryKeys) > 0 {
return primaryKeys[0], nil
}

if len(table.Columns) == 0 {
return nil, fmt.Errorf("rain: table %q has no columns for MySQL conflict DO NOTHING", table.Name)
}

// MySQL requires an assignment after ON DUPLICATE KEY UPDATE. A table without
// primary key metadata can still conflict on a unique index, so use the first
// declared column only as a visible no-op target.
return table.Columns[0], nil
}
81 changes: 79 additions & 2 deletions pkg/rain/query_insert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,34 @@ func TestInsertOnConflictPostgres(t *testing.T) {
t.Fatalf("unexpected args: %#v", args)
}
})

t.Run("targetless do nothing", func(t *testing.T) {
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("ignore errors on postgres", func(t *testing.T) {
_, _, err := db.Insert().
Table(users).
Set(users.Email, "alice@example.com").
Ignore().
ToSQL()
if err == nil || !strings.Contains(err.Error(), ".Ignore() is not supported for PostgreSQL") {
t.Fatalf("expected postgres ignore error, got %v", err)
}
})
}

func TestInsertWithCTEToSQL(t *testing.T) {
Expand Down Expand Up @@ -669,6 +697,39 @@ func TestInsertOnConflictSQLite(t *testing.T) {
if !reflect.DeepEqual(args, wantArgs) {
t.Fatalf("unexpected sqlite do update args: %#v", args)
}

t.Run("ignore", func(t *testing.T) {
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("targetless do nothing uses insert or ignore", func(t *testing.T) {
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)
}
})
}

func TestInsertOnConflictMySQL(t *testing.T) {
Expand All @@ -680,7 +741,7 @@ func TestInsertOnConflictMySQL(t *testing.T) {
}
users, _ := defineTables()

t.Run("do nothing (no-op update)", func(t *testing.T) {
t.Run("do nothing (insert ignore)", func(t *testing.T) {
sqlText, args, err := db.Insert().
Table(users).
Set(users.Email, "alice@example.com").
Expand All @@ -692,7 +753,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)
}
Expand All @@ -701,6 +762,22 @@ func TestInsertOnConflictMySQL(t *testing.T) {
}
})

t.Run("ignore", func(t *testing.T) {
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("target columns are rejected for do nothing", func(t *testing.T) {
_, _, err := db.Insert().
Table(users).
Expand Down