diff --git a/pkg/rain/query_insert.go b/pkg/rain/query_insert.go index fc834a1..90dbd90 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 + useIgnore bool // OPTIMIZATION: Internal buffers to avoid heap allocations for common // query shapes while keeping the struct size reasonable. @@ -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} @@ -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 { @@ -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 { @@ -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(" (") @@ -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 ") + } + + ctx.writeTableName(q.table) + return nil +} + func (q *InsertQuery) validateSources() error { if q.table == nil { return errors.New("rain: insert query requires a table") @@ -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 } if q.conflict.action == insertConflictActionDoUpdateSet { @@ -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)") } @@ -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 -} diff --git a/pkg/rain/query_insert_test.go b/pkg/rain/query_insert_test.go index 8e3554d..b2931ed 100644 --- a/pkg/rain/query_insert_test.go +++ b/pkg/rain/query_insert_test.go @@ -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) { @@ -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) { @@ -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"). @@ -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) } @@ -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).