Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
68 changes: 68 additions & 0 deletions contrib/drivers/sqlite/sqlite_do_insert.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Copyright GoFrame Author(https://goframe.org). All Rights Reserved.
//
// This Source Code Form is subject to the terms of the MIT License.
// If a copy of the MIT was not distributed with this file,
// You can obtain one at https://github.com/gogf/gf.

// This file implements SQLite insert behavior overrides for upsert conflict inference.

package sqlite

import (
"context"
"database/sql"
"strings"

"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gcode"
"github.com/gogf/gf/v2/errors/gerror"
)

// DoInsert inserts or updates data for given table.
func (d *Driver) DoInsert(
ctx context.Context, link gdb.Link, table string, list gdb.List, option gdb.DoInsertOption,
) (result sql.Result, err error) {
if option.InsertOption == gdb.InsertOptionSave && len(option.OnConflict) == 0 {
primaryKeys, err := d.Core.GetPrimaryKeys(ctx, table)
if err != nil {
return nil, gerror.WrapCode(
gcode.CodeInternalError,
err,
`failed to get primary keys for Save operation`,
)
}
if !saveDataHasPrimaryKeys(list, primaryKeys) {
return nil, gerror.NewCodef(
gcode.CodeMissingParameter,
`Save operation requires conflict detection: `+
`either specify OnConflict() columns or ensure table '%s' has primary keys in the data`,
table,
)
}
option.OnConflict = primaryKeys
}
return d.Core.DoInsert(ctx, link, table, list, option)
}

// saveDataHasPrimaryKeys reports whether the first save record contains all primary keys.
func saveDataHasPrimaryKeys(list gdb.List, primaryKeys []string) bool {
if len(list) == 0 || len(primaryKeys) == 0 {
return false
}
for _, primaryKey := range primaryKeys {
if !saveDataHasKey(list[0], primaryKey) {
return false
}
}
return true
}
Comment on lines +48 to +58

// saveDataHasKey reports whether the save data contains the given key case-insensitively.
func saveDataHasKey(data gdb.Map, key string) bool {
for dataKey := range data {
if strings.EqualFold(dataKey, key) {
return true
}
}
return false
}
Comment on lines +47 to +68
22 changes: 20 additions & 2 deletions contrib/drivers/sqlite/sqlite_z_unit_core_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -439,8 +439,26 @@ func Test_DB_Save(t *testing.T) {
"nickname": fmt.Sprintf(`T%d`, i),
"create_time": gtime.Now().String(),
}
_, err := db.Save(ctx, "t_user", data, 10)
gtest.AssertNE(err, nil)
result, err := db.Save(ctx, "t_user", data, 10)
t.AssertNil(err)
Comment on lines +442 to +443
rowsAffected, err := result.RowsAffected()
t.AssertNil(err)
t.Assert(rowsAffected, 1)

data["nickname"] = "T10-updated"
result, err = db.Save(ctx, "t_user", data, 10)
t.AssertNil(err)
rowsAffected, err = result.RowsAffected()
t.AssertNil(err)
t.Assert(rowsAffected, 1)
Comment on lines +446 to +453

value, err := db.Model("t_user").Where("id", i).Value("nickname")
t.AssertNil(err)
t.Assert(value.String(), "T10-updated")

delete(data, "id")
_, err = db.Save(ctx, "t_user", data, 10)
t.AssertNE(err, nil)
})
}

Expand Down
68 changes: 68 additions & 0 deletions contrib/drivers/sqlitecgo/sqlitecgo_do_insert.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Copyright GoFrame Author(https://goframe.org). All Rights Reserved.
//
// This Source Code Form is subject to the terms of the MIT License.
// If a copy of the MIT was not distributed with this file,
// You can obtain one at https://github.com/gogf/gf.

// This file implements SQLiteCGO insert behavior overrides for upsert conflict inference.

package sqlitecgo

import (
"context"
"database/sql"
"strings"

"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gcode"
"github.com/gogf/gf/v2/errors/gerror"
)

// DoInsert inserts or updates data for given table.
func (d *Driver) DoInsert(
ctx context.Context, link gdb.Link, table string, list gdb.List, option gdb.DoInsertOption,
) (result sql.Result, err error) {
if option.InsertOption == gdb.InsertOptionSave && len(option.OnConflict) == 0 {
primaryKeys, err := d.Core.GetPrimaryKeys(ctx, table)
if err != nil {
return nil, gerror.WrapCode(
gcode.CodeInternalError,
err,
`failed to get primary keys for Save operation`,
)
}
if !saveDataHasPrimaryKeys(list, primaryKeys) {
return nil, gerror.NewCodef(
gcode.CodeMissingParameter,
`Save operation requires conflict detection: `+
`either specify OnConflict() columns or ensure table '%s' has primary keys in the data`,
table,
)
}
option.OnConflict = primaryKeys
}
return d.Core.DoInsert(ctx, link, table, list, option)
}

// saveDataHasPrimaryKeys reports whether the first save record contains all primary keys.
func saveDataHasPrimaryKeys(list gdb.List, primaryKeys []string) bool {
if len(list) == 0 || len(primaryKeys) == 0 {
return false
}
for _, primaryKey := range primaryKeys {
if !saveDataHasKey(list[0], primaryKey) {
return false
}
}
return true
}
Comment on lines +48 to +58

// saveDataHasKey reports whether the save data contains the given key case-insensitively.
func saveDataHasKey(data gdb.Map, key string) bool {
for dataKey := range data {
if strings.EqualFold(dataKey, key) {
return true
}
}
return false
}
22 changes: 20 additions & 2 deletions contrib/drivers/sqlitecgo/sqlitecgo_z_unit_core_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -439,8 +439,26 @@ func Test_DB_Save(t *testing.T) {
"nickname": fmt.Sprintf(`T%d`, i),
"create_time": gtime.Now().String(),
}
_, err := db.Save(ctx, "t_user", data, 10)
gtest.AssertNE(err, nil)
result, err := db.Save(ctx, "t_user", data, 10)
t.AssertNil(err)
rowsAffected, err := result.RowsAffected()
t.AssertNil(err)
t.Assert(rowsAffected, 1)

data["nickname"] = "T10-updated"
result, err = db.Save(ctx, "t_user", data, 10)
t.AssertNil(err)
rowsAffected, err = result.RowsAffected()
t.AssertNil(err)
t.Assert(rowsAffected, 1)
Comment on lines +446 to +453

value, err := db.Model("t_user").Where("id", i).Value("nickname")
t.AssertNil(err)
t.Assert(value.String(), "T10-updated")

delete(data, "id")
_, err = db.Save(ctx, "t_user", data, 10)
t.AssertNE(err, nil)
})
}

Expand Down
2 changes: 2 additions & 0 deletions openspec/changes/sqlite-save-primary-conflict/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-08
5 changes: 5 additions & 0 deletions openspec/changes/sqlite-save-primary-conflict/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Design

The SQLite drivers will prepare `Save` insert options before delegating to the shared `gdb.Core.DoInsert` implementation. When `InsertOptionSave` is used without explicit conflict columns, each driver reads the table primary keys through `Core.GetPrimaryKeys`, verifies that the first save record includes those primary key fields, and sets `DoInsertOption.OnConflict` accordingly. Existing explicit `OnConflict(...)` behavior remains unchanged.

If no usable primary key can be found in the save data, the drivers will return a missing-parameter error with guidance to specify `OnConflict(...)` or include primary key values.
3 changes: 3 additions & 0 deletions openspec/changes/sqlite-save-primary-conflict/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# SQLite Save Primary Conflict

SQLite `Save` currently requires callers to specify `OnConflict(...)` explicitly, even when the target table has a primary key and the save data includes that primary key. This change aligns the SQLite and SQLiteCGO drivers with other upsert-capable drivers by automatically using table primary keys as the conflict target when `OnConflict(...)` is omitted.
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# sqlite-upsert Specification

## ADDED Requirements

### Requirement: SQLite Save infers primary-key conflicts
The SQLite and SQLiteCGO drivers SHALL infer the table primary key columns as the upsert conflict target for `Save` operations when callers do not explicitly provide `OnConflict(...)`.

#### Scenario: Save data includes the primary key
- **WHEN** a caller executes `Save` against a SQLite table without `OnConflict(...)`
- **AND** the table has primary key columns present in the save data
- **THEN** the driver SHALL use those primary key columns as the conflict target
- **AND** the save SHALL insert new rows or update existing rows using SQLite upsert syntax

#### Scenario: Save data has no usable primary key
- **WHEN** a caller executes `Save` against a SQLite table without `OnConflict(...)`
- **AND** the table does not have primary key columns present in the save data
- **THEN** the driver SHALL return a missing-parameter error instructing the caller to specify `OnConflict(...)` or include primary key values

#### Scenario: Explicit conflict columns are provided
- **WHEN** a caller executes `Save` against a SQLite table with `OnConflict(...)`
- **THEN** the driver SHALL use the explicitly provided conflict columns
3 changes: 3 additions & 0 deletions openspec/changes/sqlite-save-primary-conflict/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## Feedback

- [x] **FB-1**: Allow SQLite and SQLiteCGO `Save` to infer primary-key conflict columns when `OnConflict` is omitted
Loading