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
50 changes: 47 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 @@ -216,6 +217,13 @@ func (q *InsertQuery) Returning(exprs ...schema.Expression) *InsertQuery {
return q
}

// Ignore configures the INSERT to ignore conflicts.
// MySQL renders "INSERT IGNORE", SQLite renders "INSERT OR IGNORE".
func (q *InsertQuery) Ignore() *InsertQuery {
q.ignore = true
return q
}

// Prepare compiles and prepares the INSERT query.
func (q *InsertQuery) Prepare(ctx context.Context) (*PreparedInsertQuery, error) {
if q.runner == nil {
Expand Down Expand Up @@ -287,7 +295,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 +307,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 @@ -521,6 +529,36 @@ func (q *InsertQuery) assignmentsFromModelAndSet() ([]assignment, error) {
return assignments, nil
}

func (q *InsertQuery) writeInsertInto(ctx *compileContext) {
ctx.writeString("INSERT")
if q.useIgnore(ctx) {
switch ctx.dialect.Name() {
case "mysql":
ctx.writeString(" IGNORE")
case "sqlite":
ctx.writeString(" OR IGNORE")
}
}
ctx.writeString(" INTO ")
}

func (q *InsertQuery) useIgnore(ctx *compileContext) bool {
if q.ignore {
return true
}
Comment on lines +546 to +548

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 Postgres Ignore Becomes Plain Insert

When a Postgres caller uses .Ignore() without also adding .OnConflict(), this branch marks ignore as active but writeInsertInto() has no Postgres rendering case and writeConflictClause() returns because no conflict clause exists. The query compiles as a plain INSERT, so duplicate-key conflicts still fail instead of being ignored or rejected as unsupported.

Context Used: AGENTS.md (source)

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

Comment:
**Postgres Ignore Becomes Plain Insert**

When a Postgres caller uses `.Ignore()` without also adding `.OnConflict()`, this branch marks ignore as active but `writeInsertInto()` has no Postgres rendering case and `writeConflictClause()` returns because no conflict clause exists. The query compiles as a plain `INSERT`, so duplicate-key conflicts still fail instead of being ignored or rejected as unsupported.

**Context Used:** AGENTS.md ([source](https://app.greptile.com/hyperlocalise/github/hyperlocalise/rain-orm/-/custom-context?memory=3b24bd19-4fb7-4328-8fc3-52795c121ec6))

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

Fix in Codex

if q.conflict != nil && q.conflict.action == insertConflictActionDoNothing {
// Drizzle behavior: .onConflictDoNothing() without target columns on
// MySQL or SQLite renders as IGNORE.
if ctx.dialect.Name() == "mysql" {
return true
Comment on lines +552 to +553

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 Ignore Broadens Errors

Existing MySQL callers of .OnConflict().DoNothing() now get INSERT IGNORE instead of the old no-op ON DUPLICATE KEY UPDATE clause. That ignores more than duplicate keys: invalid values, truncation, or NOT NULL problems can be downgraded into warnings and coerced/default data, where the old query would still return an error.

Context Used: AGENTS.md (source)

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

Comment:
**MySQL Ignore Broadens Errors**

Existing MySQL callers of `.OnConflict().DoNothing()` now get `INSERT IGNORE` instead of the old no-op `ON DUPLICATE KEY UPDATE` clause. That ignores more than duplicate keys: invalid values, truncation, or NOT NULL problems can be downgraded into warnings and coerced/default data, where the old query would still return an error.

**Context Used:** AGENTS.md ([source](https://app.greptile.com/hyperlocalise/github/hyperlocalise/rain-orm/-/custom-context?memory=3b24bd19-4fb7-4328-8fc3-52795c121ec6))

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

Fix in Codex

}
if ctx.dialect.Name() == "sqlite" && len(q.conflict.columns) == 0 && q.conflict.constraint == "" {
return true
}
}
return false
}

func (q *InsertQuery) writeSingleRowSQL(ctx *compileContext) error {
// Single row might be from a model and/or explicit .Set() values.
// We use assignmentsFromModelAndSet which is already
Expand Down Expand Up @@ -574,7 +612,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 @@ -710,6 +748,9 @@ 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 {
if q.useIgnore(ctx) {
return nil
}
noopColumn, err := mysqlConflictNoopColumn(q.table)
if err != nil {
return err
Expand Down Expand Up @@ -743,6 +784,9 @@ func (q *InsertQuery) writeConflictClause(ctx *compileContext) error {
}

if len(q.conflict.columns) == 0 && q.conflict.constraint == "" {
if q.useIgnore(ctx) {
return nil
}
Comment on lines 786 to +789

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 Target Predicate Gets Dropped

For SQLite, .OnConflict().Where(predicate).DoNothing() has no columns or constraint but does have a target predicate. This shortcut treats it as untargeted ignore and emits INSERT OR IGNORE, silently discarding the predicate and ignoring all constraint failures instead of reporting the incomplete conflict target.

Context Used: AGENTS.md (source)

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

Comment:
**Target Predicate Gets Dropped**

For SQLite, `.OnConflict().Where(predicate).DoNothing()` has no columns or constraint but does have a target predicate. This shortcut treats it as untargeted ignore and emits `INSERT OR IGNORE`, silently discarding the predicate and ignoring all constraint failures instead of reporting the incomplete conflict target.

**Context Used:** AGENTS.md ([source](https://app.greptile.com/hyperlocalise/github/hyperlocalise/rain-orm/-/custom-context?memory=3b24bd19-4fb7-4328-8fc3-52795c121ec6))

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

Fix in Codex

return errors.New("rain: conflict clause requires at least one target (columns or constraint)")
}

Expand Down
103 changes: 101 additions & 2 deletions pkg/rain/query_insert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,105 @@ func TestInsertOnConflictPostgres(t *testing.T) {
})
}

func TestInsertIgnoreToSQL(t *testing.T) {
t.Parallel()

users, _ := defineTables()

type tc struct {
name string
dialect string
build func(*rain.DB) *rain.InsertQuery
wantSQL string
wantArgs []any
}

cases := []tc{
{
name: "mysql explicit ignore",
dialect: "mysql",
build: func(db *rain.DB) *rain.InsertQuery {
return db.Insert().Table(users).Set(users.Email, "alice@example.com").Ignore()
},
wantSQL: "INSERT IGNORE INTO `users` (`email`) VALUES (?)",
wantArgs: []any{"alice@example.com"},
},
{
name: "mysql conflict do nothing renders as ignore",
dialect: "mysql",
build: func(db *rain.DB) *rain.InsertQuery {
return db.Insert().Table(users).Set(users.Email, "alice@example.com").OnConflict().DoNothing()
},
wantSQL: "INSERT IGNORE INTO `users` (`email`) VALUES (?)",
wantArgs: []any{"alice@example.com"},
},
{
name: "sqlite explicit ignore",
dialect: "sqlite",
build: func(db *rain.DB) *rain.InsertQuery {
return db.Insert().Table(users).Set(users.Email, "alice@example.com").Ignore()
},
wantSQL: `INSERT OR IGNORE INTO "users" ("email") VALUES (?)`,
wantArgs: []any{"alice@example.com"},
},
{
name: "sqlite conflict do nothing without target renders as ignore",
dialect: "sqlite",
build: func(db *rain.DB) *rain.InsertQuery {
return db.Insert().Table(users).Set(users.Email, "alice@example.com").OnConflict().DoNothing()
},
wantSQL: `INSERT OR IGNORE INTO "users" ("email") VALUES (?)`,
wantArgs: []any{"alice@example.com"},
},
{
name: "sqlite conflict do nothing with target renders on conflict",
dialect: "sqlite",
build: func(db *rain.DB) *rain.InsertQuery {
return db.Insert().Table(users).Set(users.Email, "alice@example.com").OnConflict(users.Email).DoNothing()
},
wantSQL: `INSERT INTO "users" ("email") VALUES (?) ON CONFLICT ("email") DO NOTHING`,
wantArgs: []any{"alice@example.com"},
},
{
name: "mysql insert select ignore",
dialect: "mysql",
build: func(db *rain.DB) *rain.InsertQuery {
subquery := db.Select().Table(users).Column(users.Email)
return db.Insert().Table(users).Columns(users.Email).Select(subquery).Ignore()
},
wantSQL: "INSERT IGNORE INTO `users` (`email`) SELECT `users`.`email` FROM `users`",
},
}

for _, tt := range cases {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

db, err := rain.OpenDialect(tt.dialect)
if err != nil {
t.Fatalf("OpenDialect returned error: %v", err)
}

sqlText, args, err := tt.build(db).ToSQL()
if err != nil {
t.Fatalf("ToSQL returned error: %v", err)
}
if sqlText != tt.wantSQL {
t.Fatalf("unexpected SQL:\nwant: %s\ngot: %s", tt.wantSQL, sqlText)
}
if len(args) != len(tt.wantArgs) {
t.Fatalf("unexpected arg count: want %d got %d (%#v)", len(tt.wantArgs), len(args), args)
}
for idx := range tt.wantArgs {
if args[idx] != tt.wantArgs[idx] {
t.Fatalf("unexpected arg[%d]: want %#v got %#v", idx, tt.wantArgs[idx], args[idx])
}
}
})
}
}

func TestInsertWithCTEToSQL(t *testing.T) {
t.Parallel()

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

t.Run("do nothing (no-op update)", func(t *testing.T) {
t.Run("do nothing (renders as IGNORE)", func(t *testing.T) {
sqlText, args, err := db.Insert().
Table(users).
Set(users.Email, "alice@example.com").
Expand All @@ -692,7 +791,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 Down