fix(model): return string from JSON column Valuers for pg simple protocol

With PrepareStmt disabled, PostgreSQL queries run over pgx's simple
protocol, which encodes every []byte parameter as a bytea hex literal
('\x...'). driver.Valuer implementations returning []byte from
json.Marshal therefore fail json-column writes with SQLSTATE 22P02
(reported on the channels UPDATE path via ChannelInfo).

Reproduced against a live PostgreSQL 16: []byte Valuer into a json
column fails under simple protocol, string succeeds; []byte into a
text column silently stores the hex literal (no such path exists in
the repo today — audited all Valuers, json.RawMessage fields, and raw
SQL call sites).

- ChannelInfo, Properties, TaskPrivateData, JSONValue Value() now
  return string; zero-value nil semantics unchanged. Task.Data
  (bare json.RawMessage) is unaffected — database/sql's default
  converter already passes it as expected.
- Their Scan() counterparts now accept both []byte and string via a
  shared jsonScanBytes helper: SQLite returns string for these columns
  once Value() emits string, and the old []byte-only assertions
  silently zeroed the field (caught by the model test suite).
- Add regression tests locking both contracts: json-column Valuers
  must return string (or nil for zero values), Scanners must accept
  []byte and string.

Verified end-to-end against PostgreSQL 16 with the real model types:
Channel create/update/read-back, Task json fields, PrefillGroup items.
This commit is contained in:
CaIon
2026-08-30 21:13:21 +08:00
parent 1751f43ee0
commit 6eb6f35ed2
5 changed files with 133 additions and 8 deletions
+8 -3
View File
@@ -162,14 +162,19 @@ func ApplyChannelGroupFilter(query *gorm.DB, group string) *gorm.DB {
} }
// Value implements driver.Valuer interface // Value implements driver.Valuer interface
// 必须返回 string 而非 []byte:PG simple protocol 下 []byte 参数按 bytea
// 编码,写 json 列会触发 SQLSTATE 22P02。
func (c ChannelInfo) Value() (driver.Value, error) { func (c ChannelInfo) Value() (driver.Value, error) {
return common.Marshal(&c) b, err := common.Marshal(&c)
if err != nil {
return nil, err
}
return string(b), nil
} }
// Scan implements sql.Scanner interface // Scan implements sql.Scanner interface
func (c *ChannelInfo) Scan(value interface{}) error { func (c *ChannelInfo) Scan(value interface{}) error {
bytesValue, _ := value.([]byte) return common.Unmarshal(jsonScanBytes(value), c)
return common.Unmarshal(bytesValue, c)
} }
func (channel *Channel) GetKeys() []string { func (channel *Channel) GetKeys() []string {
+94
View File
@@ -0,0 +1,94 @@
package model
import (
"database/sql/driver"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// 保护契约:PostgreSQL 走 simple protocol(PrepareStmt 关闭)时,driver.Valuer
// 返回 []byte 会被 pgx 按 bytea 十六进制字面量编码,写入 json 列触发
// SQLSTATE 22P02。所有 json 列的 Value() 必须返回 string(或 nil)。
func TestJSONColumnValuersReturnString(t *testing.T) {
testCases := []struct {
name string
valuer driver.Valuer
want string
}{
{
name: "ChannelInfo",
valuer: ChannelInfo{IsMultiKey: true, MultiKeySize: 2},
want: `{"is_multi_key":true,"multi_key_size":2,"multi_key_status_list":null,"multi_key_polling_index":0,"multi_key_mode":""}`,
},
{
name: "Properties",
valuer: Properties{Input: "hello"},
want: `{"input":"hello"}`,
},
{
name: "TaskPrivateData",
valuer: TaskPrivateData{Key: "k"},
want: `{"key":"k"}`,
},
{
name: "JSONValue",
valuer: JSONValue(`[{"k":"v"}]`),
want: `[{"k":"v"}]`,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
value, err := testCase.valuer.Value()
require.NoError(t, err)
str, ok := value.(string)
require.True(t, ok, "Value() must return string, got %T", value)
assert.JSONEq(t, testCase.want, str)
})
}
}
// 空值仍返回 nil,保持列的 NULL 语义。
func TestJSONColumnValuersZeroValueIsNil(t *testing.T) {
for name, valuer := range map[string]driver.Valuer{
"Properties": Properties{},
"TaskPrivateData": TaskPrivateData{},
"JSONValue": JSONValue(nil),
} {
t.Run(name, func(t *testing.T) {
value, err := valuer.Value()
require.NoError(t, err)
assert.Nil(t, value)
})
}
}
// 保护契约:json 列的 Scan 必须同时接受 []byte 与 string——不同驱动/协议
// 模式返回类型不同,静默丢弃 string 会把已有数据清零。
func TestJSONColumnScannersAcceptStringAndBytes(t *testing.T) {
toInput := func(kind string, payload string) interface{} {
if kind == "bytes" {
return []byte(payload)
}
return payload
}
for _, kind := range []string{"bytes", "string"} {
t.Run(kind, func(t *testing.T) {
var info ChannelInfo
require.NoError(t, info.Scan(toInput(kind, `{"is_multi_key":true,"multi_key_size":2}`)))
assert.True(t, info.IsMultiKey)
assert.Equal(t, 2, info.MultiKeySize)
var props Properties
require.NoError(t, props.Scan(toInput(kind, `{"input":"hello"}`)))
assert.Equal(t, "hello", props.Input)
var private TaskPrivateData
require.NoError(t, private.Scan(toInput(kind, `{"key":"k"}`)))
assert.Equal(t, "k", private.Key)
})
}
}
+13
View File
@@ -27,6 +27,19 @@ var commonFalseVal string
var logKeyCol string var logKeyCol string
var logGroupCol string var logGroupCol string
// jsonScanBytes 归一化 json 列的驱动返回值:不同驱动/协议模式下同一列可能
// 以 []byte 或 string 返回,静默丢弃 string 会导致字段被清零而不报错。
func jsonScanBytes(value interface{}) []byte {
switch v := value.(type) {
case []byte:
return v
case string:
return []byte(v)
default:
return nil
}
}
func initCol() { func initCol() {
// init common column names // init common column names
if common.UsingMainDatabase(common.DatabaseTypePostgreSQL) { if common.UsingMainDatabase(common.DatabaseTypePostgreSQL) {
+3 -1
View File
@@ -20,11 +20,13 @@ import (
type JSONValue json.RawMessage type JSONValue json.RawMessage
// Value 实现 driver.Valuer 接口,用于数据库写入 // Value 实现 driver.Valuer 接口,用于数据库写入
// 必须返回 string 而非 []byte:PG simple protocol 下 []byte 按 bytea 编码,
// 写 json 列会触发 SQLSTATE 22P02。
func (j JSONValue) Value() (driver.Value, error) { func (j JSONValue) Value() (driver.Value, error) {
if j == nil { if j == nil {
return nil, nil return nil, nil
} }
return []byte(j), nil return string(j), nil
} }
// Scan 实现 sql.Scanner 接口,兼容不同驱动返回的类型 // Scan 实现 sql.Scanner 接口,兼容不同驱动返回的类型
+15 -4
View File
@@ -87,7 +87,7 @@ type Properties struct {
} }
func (m *Properties) Scan(val interface{}) error { func (m *Properties) Scan(val interface{}) error {
bytesValue, _ := val.([]byte) bytesValue := jsonScanBytes(val)
if len(bytesValue) == 0 { if len(bytesValue) == 0 {
*m = Properties{} *m = Properties{}
return nil return nil
@@ -99,7 +99,13 @@ func (m Properties) Value() (driver.Value, error) {
if m == (Properties{}) { if m == (Properties{}) {
return nil, nil return nil, nil
} }
return common.Marshal(m) // 必须返回 string 而非 []byte:PG simple protocol 下 []byte 按 bytea 编码,
// 写 json 列会触发 SQLSTATE 22P02。
b, err := common.Marshal(m)
if err != nil {
return nil, err
}
return string(b), nil
} }
type TaskPrivateData struct { type TaskPrivateData struct {
@@ -180,7 +186,7 @@ func GenerateTaskID() string {
} }
func (p *TaskPrivateData) Scan(val interface{}) error { func (p *TaskPrivateData) Scan(val interface{}) error {
bytesValue, _ := val.([]byte) bytesValue := jsonScanBytes(val)
if len(bytesValue) == 0 { if len(bytesValue) == 0 {
return nil return nil
} }
@@ -191,7 +197,12 @@ func (p TaskPrivateData) Value() (driver.Value, error) {
if (p == TaskPrivateData{}) { if (p == TaskPrivateData{}) {
return nil, nil return nil, nil
} }
return common.Marshal(p) // 同 Properties.Value:string 避免 PG simple protocol 的 bytea 编码。
b, err := common.Marshal(p)
if err != nil {
return nil, err
}
return string(b), nil
} }
// SyncTaskQueryParams 用于包含所有搜索条件的结构体,可以根据需求添加更多字段 // SyncTaskQueryParams 用于包含所有搜索条件的结构体,可以根据需求添加更多字段