From 6f10c257812a5bc44df17d8abf2c78d914f05168 Mon Sep 17 00:00:00 2001 From: John Guo Date: Fri, 8 May 2026 14:18:19 +0800 Subject: [PATCH] fix(contrib/drivers/sqlite): infer save conflict from primary keys --- contrib/drivers/sqlite/sqlite_do_insert.go | 68 +++++++++++++++++++ .../drivers/sqlite/sqlite_z_unit_core_test.go | 22 +++++- .../drivers/sqlitecgo/sqlitecgo_do_insert.go | 68 +++++++++++++++++++ .../sqlitecgo/sqlitecgo_z_unit_core_test.go | 22 +++++- .../.openspec.yaml | 2 + .../sqlite-save-primary-conflict/design.md | 5 ++ .../sqlite-save-primary-conflict/proposal.md | 3 + .../specs/sqlite-upsert/spec.md | 21 ++++++ .../sqlite-save-primary-conflict/tasks.md | 3 + 9 files changed, 210 insertions(+), 4 deletions(-) create mode 100644 contrib/drivers/sqlite/sqlite_do_insert.go create mode 100644 contrib/drivers/sqlitecgo/sqlitecgo_do_insert.go create mode 100644 openspec/changes/sqlite-save-primary-conflict/.openspec.yaml create mode 100644 openspec/changes/sqlite-save-primary-conflict/design.md create mode 100644 openspec/changes/sqlite-save-primary-conflict/proposal.md create mode 100644 openspec/changes/sqlite-save-primary-conflict/specs/sqlite-upsert/spec.md create mode 100644 openspec/changes/sqlite-save-primary-conflict/tasks.md diff --git a/contrib/drivers/sqlite/sqlite_do_insert.go b/contrib/drivers/sqlite/sqlite_do_insert.go new file mode 100644 index 000000000..f03721261 --- /dev/null +++ b/contrib/drivers/sqlite/sqlite_do_insert.go @@ -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 +} diff --git a/contrib/drivers/sqlite/sqlite_z_unit_core_test.go b/contrib/drivers/sqlite/sqlite_z_unit_core_test.go index 6d02ee678..4f4efbd6b 100644 --- a/contrib/drivers/sqlite/sqlite_z_unit_core_test.go +++ b/contrib/drivers/sqlite/sqlite_z_unit_core_test.go @@ -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) }) } diff --git a/contrib/drivers/sqlitecgo/sqlitecgo_do_insert.go b/contrib/drivers/sqlitecgo/sqlitecgo_do_insert.go new file mode 100644 index 000000000..dadea93f8 --- /dev/null +++ b/contrib/drivers/sqlitecgo/sqlitecgo_do_insert.go @@ -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 +} diff --git a/contrib/drivers/sqlitecgo/sqlitecgo_z_unit_core_test.go b/contrib/drivers/sqlitecgo/sqlitecgo_z_unit_core_test.go index 8c4e467c1..2d9d7d988 100644 --- a/contrib/drivers/sqlitecgo/sqlitecgo_z_unit_core_test.go +++ b/contrib/drivers/sqlitecgo/sqlitecgo_z_unit_core_test.go @@ -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) }) } diff --git a/openspec/changes/sqlite-save-primary-conflict/.openspec.yaml b/openspec/changes/sqlite-save-primary-conflict/.openspec.yaml new file mode 100644 index 000000000..054b8c013 --- /dev/null +++ b/openspec/changes/sqlite-save-primary-conflict/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-08 diff --git a/openspec/changes/sqlite-save-primary-conflict/design.md b/openspec/changes/sqlite-save-primary-conflict/design.md new file mode 100644 index 000000000..ca1b075f6 --- /dev/null +++ b/openspec/changes/sqlite-save-primary-conflict/design.md @@ -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. diff --git a/openspec/changes/sqlite-save-primary-conflict/proposal.md b/openspec/changes/sqlite-save-primary-conflict/proposal.md new file mode 100644 index 000000000..358739cd2 --- /dev/null +++ b/openspec/changes/sqlite-save-primary-conflict/proposal.md @@ -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. diff --git a/openspec/changes/sqlite-save-primary-conflict/specs/sqlite-upsert/spec.md b/openspec/changes/sqlite-save-primary-conflict/specs/sqlite-upsert/spec.md new file mode 100644 index 000000000..882b4e7dd --- /dev/null +++ b/openspec/changes/sqlite-save-primary-conflict/specs/sqlite-upsert/spec.md @@ -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 diff --git a/openspec/changes/sqlite-save-primary-conflict/tasks.md b/openspec/changes/sqlite-save-primary-conflict/tasks.md new file mode 100644 index 000000000..e96592025 --- /dev/null +++ b/openspec/changes/sqlite-save-primary-conflict/tasks.md @@ -0,0 +1,3 @@ +## Feedback + +- [x] **FB-1**: Allow SQLite and SQLiteCGO `Save` to infer primary-key conflict columns when `OnConflict` is omitted