fix(contrib/drivers/sqlite): infer save conflict from primary keys

This commit is contained in:
John Guo
2026-05-08 14:18:19 +08:00
parent 94623a19d1
commit 6f10c25781
9 changed files with 210 additions and 4 deletions

View File

@ -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
}
// 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
}

View File

@ -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)
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)
})
}

View File

@ -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
}
// 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
}

View File

@ -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)
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)
})
}

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-08

View File

@ -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.

View File

@ -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.

View File

@ -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

View File

@ -0,0 +1,3 @@
## Feedback
- [x] **FB-1**: Allow SQLite and SQLiteCGO `Save` to infer primary-key conflict columns when `OnConflict` is omitted