Skip to content

feat(database/gdb): support to Scan field type value to pointer of basic or slice types - #4716

Open
wanghaolong613 wants to merge 37 commits into
gogf:masterfrom
wanghaolong613:feature/sql-scan
Open

feat(database/gdb): support to Scan field type value to pointer of basic or slice types#4716
wanghaolong613 wants to merge 37 commits into
gogf:masterfrom
wanghaolong613:feature/sql-scan

Conversation

@wanghaolong613

@wanghaolong613 wanghaolong613 commented Feb 28, 2026

Copy link
Copy Markdown
Contributor

fix: #3977
Scan 支持将查询的结果直接反序列化至指定变量

Copilot AI left a comment

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.

Pull request overview

Adds support for scanning query results directly into pointers to basic types and into slices of basic/pointer element types, addressing gf issue #3977.

Changes:

  • Extend Model.Scan to support scanning single-field results into basic types (and scanner-backed types) without struct mapping.
  • Improve gconv.Converter.Scan slice handling to populate pointer elements.
  • Add MySQL issue test data and a regression test covering pointer/basic/slice scan scenarios.

Reviewed changes

Copilot reviewed 6 out of 7 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
util/gconv/internal/converter/converter_scan.go Enhances slice element scanning to support pointer element allocation/dereference.
util/gconv/internal/converter/converter_bool.go Adds handling for reflect.Value inputs to Bool conversion.
database/gdb/gdb_model_select.go Extends Model.Scan to route single-field scans into basic/scanner types.
contrib/drivers/mysql/testdata/issues/3977.sql Adds schema/data fixture for issue #3977 regression tests.
contrib/drivers/mysql/mysql_z_unit_issue_test.go Adds regression tests covering Scan into basic/pointer/slice and decimal/scanner types.
contrib/drivers/mysql/go.mod Adds shopspring/decimal dependency for the new test.
contrib/drivers/mysql/go.sum Records checksums for the added dependency.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread util/gconv/internal/converter/converter_bool.go
Comment thread database/gdb/gdb_model_select.go Outdated
Comment thread contrib/drivers/mysql/mysql_z_unit_issue_test.go Outdated
Comment thread contrib/drivers/mysql/mysql_z_unit_issue_test.go Outdated

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 15 out of 16 changed files in this pull request and generated 1 comment.

Suppressed comments (4)

database/gdb/gdb_model_select.go:389

  • When the query returns no rows, Model.Value() returns (nil, nil). Calling value.Scan(pointer) will panic. This branch should return sql.ErrNoRows when no row is found.
		value, err := m.Value(args...)
		if err != nil {
			return err
		}
		return value.Scan(pointer)

database/gdb/gdb_model_select.go:383

  • The error message for basic-type Scan doesn't tell callers what to do (use Fields()) and is inconsistent with the sql.Scanner/slice branches. Consider aligning it to explicitly mention Fields() and the 1-field requirement.
		if len(m.fields) != 1 {
			return gerror.NewCode(
				gcode.CodeInvalidParameter,
				fmt.Sprintf("Scan operation failed: expected 1 field, but got %d", len(m.fields)),
			)

contrib/drivers/mysql/go.mod:9

  • Adding github.com/shopspring/decimal to this module's go.mod makes it a transitive dependency for all users of contrib/drivers/mysql, even though it's only used by tests here. Consider replacing it with a small local sql.Scanner test type (or stdlib sql.Null* types) so the driver module doesn't gain an extra runtime dependency.
	github.com/go-sql-driver/mysql v1.7.1
	github.com/gogf/gf/v2 v2.10.2
	github.com/shopspring/decimal v1.4.0
)

os/gres/testdata/data/data.go:7

  • This change replaces a large compressed testdata blob in an unrelated package (os/gres/testdata). It significantly increases review noise and risks altering gres tests without being part of the stated Scan enhancement; please split it into a separate PR or revert it if it isn't required.
func init() {
	if err := gres.Add("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"); err != nil {
		panic("add binary content to resource manager failed: " + err.Error())

Comment thread database/gdb/gdb_model_select.go Outdated
@hailaz

hailaz commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

概述

Resolves #3977

本 PR 扩展了 Model.Scan(),支持将单个数据库字段直接扫描到 Go 基本类型(intfloat64stringbool 等)、基本类型的指针、基本类型的切片,以及基本类型指针的切片。此前 Scan() 仅支持 struct/*struct/[]struct/[]*struct

新增用法示例

// 单个基本类型值
var name string
err := db.Model("user").Fields("name").Where("id", 1).Scan(&name)

// 基本类型切片
var names []string
err := db.Model("user").Fields("name").Scan(&names)

// 基本类型指针切片
var names2 []*string
err := db.Model("user").Fields("name").Scan(&names2)

// 支持配合 FieldSum 等字段生成辅助方法使用
var total int64
err := db.Model("user").FieldSum("id").WhereIn("id", []int64{1, 2, 3}).Scan(&total)

// 支持 sql.Scanner 类型(如 decimal.Decimal、gtime.Time)
var balance decimal.Decimal
err := db.Model("user").Fields("balance").Where("id", 1).Scan(&balance)

约束:当扫描目标为基本类型或 sql.Scanner 类型时,必须通过 Fields()(或 FieldSum 等字段生成辅助方法)指定且仅指定一个字段,否则将返回 InvalidParameter 错误。

改动说明

database/gdb/gdb_model_select.go

重构了 Model.Scan(),根据指针的原始类型种类(origin kind)进行路由分发:

指针类型 路由
*[]T / *[]*T,其中 T 为基本类型或 sql.Scanner 新增基本类型切片分支 → Array().Scan()
*[]struct / *[]*struct doStructs()(保持不变)
*T / **T,其中 Tsql.Scanner 新增 Scanner 分支 → Value().Scan()
*struct doStruct()(保持不变)
*int / *string / *float64 / *bool 新增基本类型分支 → Value().Scan()

关键细节:

  • 使用 reflection.IsBasicKind() 正向识别基本类型种类,确保 gdb.Result[]map[string]Value)等非 struct 切片能正确落入 doStructs() 分支。
  • 同时检查 T*T 是否实现了 sql.Scanner(通过 reflect.PointerTo),因为 decimal.Decimalgtime.Time 等类型在指针接收者上实现 Scan() 方法。

internal/reflection/reflection.go

新增 IsBasicKind(kind reflect.Kind) bool —— 一个通用的反射工具函数,用于判断给定种类是否为基本类型(int/uint/float/bool/string 及其定长变体)。放置在此包中,与现有的反射辅助函数(OriginTypeAndKindOriginValueAndKind)并列。

util/gconv/internal/converter/

  • converter_bool.goconverter_float.goconverter_int.goconverter_string.goconverter_uint.go:在各转换器(BoolFloat32Float64Int64Uint64String)的 default 分支中新增 reflect.Value 解包处理。当输入为 reflect.Value 时,先通过 Interface() 解包再进行转换。这是 gdb 扫描路径可能传入 reflect.Value 类型输入所必需的。
  • converter_scan.go:新增对元素为基本类型指针的切片(如 []*int[]*string)的支持。当切片元素为 nil 指针时,先通过 reflect.New() 分配新值再赋值。

测试

contrib/drivers/mysql/mysql_z_unit_issue_test.go

新增 Test_Issue3977,针对真实 MySQL 数据库覆盖所有新增扫描场景:

分类 测试类型
字符串 string*string[]string[]*string
浮点数 float64*float64[]float64[]*float64FieldSum
整数 int*int[]int64[]*int64FieldSum
布尔值 bool*bool[]bool[]*bool
sql.Scanner decimal.Decimal*decimal.Decimal[]*decimal.Decimalgtime.Time*gtime.Time[]*gtime.Time
错误场景 decimal.Decimal 未指定 Fields() → 预期返回 InvalidParameter 错误

util/gconv/gconv_z_unit_scan_basic_types_test.go

  • TestScanReflectValueInput:验证 gconv.Bool/Int64/Int/Uint64/Float64/Float32/String 能正确解包 reflect.Value 输入。
  • TestScanPointerElementSlice:验证扫描到 []*string[]*int[]*int64[]*float64[]*bool,以及跨类型转换([]string[]*int)。

internal/reflection/reflection_test.go

  • Test_IsBasicKind:测试所有基本类型种类(返回 true)和非基本类型种类 —— mapslicestructpointerinterfacechanfunccomplex(返回 false)。覆盖率 100%

已知限制

[]decimal.Decimal[]gtime.Timesql.Scanner 类型的值切片)不受支持 —— 仅支持指针切片([]*decimal.Decimal[]*gtime.Time)。这是设计使然:这些类型在指针接收者上实现 sql.Scanner,因此值类型本身不满足 sql.Scanner 接口。

@hailaz
hailaz requested a review from lingcoder August 11, 2026 08:02
@lingcoder

lingcoder commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

在真实 MySQL 8.0 上对 master(41baf1e28)与本 PR(70ed7de51)做了逐项对照,发现 3 个新引入的问题、1 个功能缺口和 1 处无效改动。先说好的部分。

本 PR 确实修掉的两条静默错值路径

master 上 Scan() 到基础类型切片会静默返回垃圾数据,本 PR 修对了:

var names []string
db.Model("demo").Fields("name").Scan(&names)
// master: [{"name":"alice"} {"name":"bob"}]   ← 静默返回 JSON 行字符串
// 本 PR : [alice bob]                          ← 正确

var ages []int
db.Model("demo").Fields("age").Group("age").Scan(&ages)
// master: [0 0]        ← 静默返回 0
// 本 PR : [18 20]      ← 正确

util/gconv/internal/converter/converter_scan.go[]*T 的支持也修了一个 master 上的 panic:

vs := gvar.Vars{gvar.New(1), gvar.New(2), gvar.New(3)}
var ids []*int64
vs.Scan(&ids)
// master: panic: reflect: call of reflect.Value.SetInt on ptr Value (converter_scan.go:206)
// 本 PR : err=<nil>,正确

converter_{int,uint,float,bool}.go 解包 reflect.Value 同样是真修复,master 上 gconv.Int64(reflect.ValueOf(42)) 返回 0gconv.Bool(reflect.ValueOf(false)) 返回 true

这几处建议保留。下面是新引入的问题。


问题 1(P1):Fields("*") 触发 SQL 语法错误

var name string
err := db.Model("demo").Fields("*").Where("id", 1).Scan(&name)
表现
master err=element of parameter "pointer" for function Scan should type of struct/*struct/[]struct/[]*struct
本 PR err=SELECT *,* FROM demo WHERE id=1 LIMIT 1: Error 1064 (42000): You have an error in your SQL syntax...

根因是三条新分支都写成:

args := append([]any{m.fields[0]}, where...)
value, err := m.Value(args...)

Model.Fields() 是 append 语义(appendToFields 不去重),Value()/Array() 内部又会再 m.Fields(gconv.String(fieldsAndWhere[0])) 一次,字段因此翻倍:

SELECT `name`,`name` FROM `demo` WHERE `id`=1 LIMIT 1
SELECT SUM(`x`),SUM(`x`) FROM `demo` LIMIT 1
SELECT CONCAT(name,'-',age),CONCAT(name,'-',age) FROM `demo` WHERE `id`=1 LIMIT 1
SELECT *,* FROM `demo` WHERE `id`=1 LIMIT 1          ← 语法错误

另外默认非 safe 模式下 getModel() 返回的是 m 本身,这一步等于原地污染了 model 的 fields

字段已经在 m.fields 里了,无需回传:

model := m
if len(where) > 0 {
    model = m.Where(where[0], where[1:]...)
}
value, err := model.Value()

问题 2(P1):gdb.Raw 绕过守卫,静默只返回第一列

var v string
err := db.Model("demo").Fields(gdb.Raw("name,age")).Where("id", 1).Scan(&v)
表现
master err=element of parameter "pointer" for function Scan should type of...,明确拒绝
本 PR value="alice" err=<nil>
sql=SELECT name,age,name,age FROM demo WHERE id=1 LIMIT 1

gdb.Raw 不会被 filterFieldsFrom 按逗号拆分,所以 len(m.fields) == 1 通过了守卫,但结果集实际有 2 列,Value()FirstResultColumn 静默取了第一列。没有 error、没有 warning,调用方拿到一个看起来正常的错值。

守卫查的是 Fields() 的参数个数,不是结果列数,两者并不等价。建议改为校验实际结果列数(Value()/Array() 内部已有 getRecordFields() 的列数检查,可以复用),并相应调整错误文案——现在的 requires exactly 1 field specified via Fields() 在这个场景下是误导性的。

注:普通的 Fields("name,id") 会被 gf 正常拆成 2 个 field,守卫能拦住,这条只在 gdb.Raw 下触发。

问题 3(P1):自带 Scan 方法的 struct 被劫持

type Point struct{ X, Y float64 }
func (p *Point) Scan(v any) error { ... }   // 指针接收者,*Point 实现 sql.Scanner

var p Point
err := db.Model("demo").Fields("x", "y").Where("id", 1).Scan(&p)
表现
master result={X:1.5 Y:2.5} err=<nil>,正常按字段填充
本 PR result={X:0 Y:0} err=Scan into sql.Scanner type requires exactly 1 field specified via Fields(), but got 2

触发条件是「*T 实现了 sql.Scanner,同时 T 的导出字段本来就能映射到多列」。Scanner 分支不应该 return error,条件不满足时应当 fall through 回 doStruct

if len(m.fields) == 1 && implementsScanner(elemType) {
    return m.doScanSingleField(pointer, where...)
}
return m.doStruct(pointer, where...)

这样改同时也解决了向后兼容问题。

补充一点:内嵌 gtime.Time / decimal.Decimal / sql.NullString 的 struct 虽然也会命中这条分支,但 master 上本来就是坏的(会把内嵌类型的非导出字段当列名,生成 SELECT wall,ext,loc,...),本 PR 把它从「查询到数据库才报 Unknown column」提前成入口报错,这部分不算退步。


功能缺口:FieldsEx 场景下新功能不可用

var age int
db.Model("demo").FieldsEx("id", "name", "x", "y", "blob_col").Where("id", 1).Scan(&age)
// 本 PR: err=Scan operation failed: expected 1 field, but got 0

FieldsEx 已经把结果收敛到唯一一列,但守卫看的是 len(m.fields)(此时为 0)。这也是建议把守卫改为「校验结果列数」的另一个理由。

无效改动:converter_string.go 的解包是死代码

reflect.Value 自带 String() string 方法,天然满足 localinterface.IString,而新增的解包被放在 IString 检查之后,因此对非 string 的 reflect.Value 永远不可达:

gconv.String(reflect.ValueOf(42))    // "<int Value>"    ← 本 PR 之后仍然如此
gconv.String(reflect.ValueOf(3.14))  // "<float64 Value>"
gconv.String(reflect.ValueOf(true))  // "<bool Value>"
gconv.String(reflect.ValueOf("hi"))  // "hi"  ← 唯一正确的一例,是走 IString 巧合对的

TestScanReflectValueInput 只测了 reflect.ValueOf("hello"),所以没暴露。把解包移到 IString 检查之前即可,测试也建议补上非 string 的 kind。


其他

代码风格上的一些小点:

  • 三段近乎相同的「恰好 1 个字段」守卫,配了三种不同措辞的错误文案,建议抽成一个 helper

  • reflect.TypeFor[sql.Scanner]() 内联调用了 5 次,建议提为包级变量;相关判定单行已达 181 字符

  • 用了 gerror.NewCode + fmt.Sprintf,项目里有现成的 gerror.NewCodef

  • converter_scan.goelem 算完后,default 分支又重新取了 target := newSlice.Index(i)elem 在该分支成为无用变量

  • contrib/drivers/mysql/go.mod 为测试引入了 shopspring/decimal 直接依赖,需要确认是否可接受

关于 #3977 的范围

顺便说明一下,#3977 原文里「val.Scan(&id) 不支持基本数据类型」这个描述已经过期了——#3991(2024-12-18 合入)给 gconv.Scan 加了基础类型支持,现在 Value().Scan(&int64)Value().Int64()Array().Int64s() 都是通的,issue 里那四个诉求用现有 API 都能拿到值。

所以本 PR 在 database/gdb 的价值主要有两块:一是把 Scan() 统一成单一入口(便利性),二是修掉了 Scan(&[]基础类型) 的静默错值(实质修复)。这两点都成立,只是目前的实现代价偏大。上面 3 个问题改掉之后,整体就是净正向的。

@hailaz

hailaz commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

PR #4716 修复问题总结

概述

PR #4716 (feat(database/gdb): support to Scan field type value to pointer of basic or slice types) 为 GoFrame 框架的 Model.Scan() 方法增加了直接扫描基础类型(int, float64, string, bool 等)及其指针/切片变体的能力,无需结构体映射。

本分支 pr/wanghaolong613/4716 在 PR 原始提交基础上,修复了 Code Review 中发现的所有问题以及开发过程中发现的边界情况。


修复问题清单

P1 级别(阻塞性问题)

1. 字段重复问题

  • 问题描述Scan() 调用 Value()Array() 时传入字段参数,而 Fields 是追加模式(append-only),导致字段在 SQL 中重复出现。
  • 修复方案:使用 m.Clone() 克隆模型,并且不向 Value()/Array() 传递字段参数(字段已存在于 m.fields 中)。
  • 涉及文件database/gdb/gdb_model_select.go

2. gdb.Raw 字段绕过检查

  • 问题描述len(m.fields) == 1 的检查无法区分普通字段和 gdb.Raw("name,age")。Raw 字段虽然在 m.fields 中只占一个条目,但在 SQL 层面可能展开为多列。
  • 修复方案:在 isSingleFieldSpecified() 中增加 Raw 类型断言检查,拒绝 Raw 类型字段进入单列扫描路径。
  • 涉及文件database/gdb/gdb_model_select.go

3. Scanner 结构体被劫持

  • 问题描述:当目标类型实现了 sql.Scanner 接口(如 decimal.Decimalgtime.Time)但未指定字段时,doStruct 会自动选择该结构体的字段(通常是未导出字段),生成错误的 SQL。
  • 修复方案:在 Scanner 分支中增加 isSingleFieldSpecified() 守卫,确保必须明确指定一个字段才能进入 Scanner 扫描路径。
  • 涉及文件database/gdb/gdb_model_select.go

P2 级别(功能缺陷)

4. FieldsEx 空隙问题

  • 问题描述:当使用 FieldsEx 排除字段时,m.fields 为空,但 FieldsEx 可能将结果缩小到单列。原逻辑仅检查 len(m.fields) == 1,导致 FieldsEx 场景被错误拒绝。
  • 修复方案:在 validateSingleFieldSpecified() 中增加 len(m.fieldsEx) > 0 的放行条件。
  • 涉及文件database/gdb/gdb_model_select.go

5. Fields("*") / Fields("a.*") 通配符边界情况

  • 问题描述:通配符 *a.*m.fields 中存储为单个字符串字段,通过了 len(m.fields) == 1 检查,但在 SQL 层面会展开为所有列。RowsToResultFirstResultColumn = columnTypes[0].Name() 的设置导致静默返回第一列值,行为不可预测。
  • 修复方案:新增 isExpandingField() 函数,使用类型开关高效判断字段是否会扩展为多列——覆盖 Raw/*Raw"*""a.*" 通配符模式,但正确放行 COUNT(*) 等聚合函数(因为它们始终产生单列)。
  • 涉及文件database/gdb/gdb_model_select.go, database/gdb/gdb_z_unit_issue_test.go

P3 级别(代码质量改进)

6. converter_string.go 死代码修复

  • 问题描述reflect.Value 的解包操作放在了 IString 接口检查之后,导致当输入是 reflect.Value 包装的 IString 类型时,解包永远不会执行。
  • 修复方案:将 reflect.Value 解包逻辑移到 IString 检查之前。
  • 涉及文件util/gconv/internal/converter/converter_string.go

7. converter_scan.go 未使用变量

  • 问题描述:切片扫描逻辑中存在变量 elem 的重复声明,代码冗余。
  • 修复方案:统一使用 target 变量名,消除重复声明。
  • 涉及文件util/gconv/internal/converter/converter_scan.go

8. 辅助方法合并与重构

  • 问题描述validateSingleFieldSpecified()isSingleFieldSpecified() 中存在重复的 Raw 检查逻辑。
  • 修复方案validateSingleFieldSpecified() 改为调用 isSingleFieldSpecified(),消除重复逻辑。Scanner 分支中的重复 doStruct 调用合并为单个条件判断。
  • 涉及文件database/gdb/gdb_model_select.go

新增测试

测试文件 测试函数 覆盖场景
database/gdb/gdb_z_unit_issue_test.go Test_ScanValidateSingleFieldSpecified 9 个用例:无字段、单字段、双字段、Raw 字段、FieldsEx、Raw("name")、*a.*COUNT(*)
util/gconv/gconv_z_unit_scan_basic_types_test.go TestScanReflectValueInput reflect.Value 包装的基础类型输入
util/gconv/gconv_z_unit_scan_basic_types_test.go TestScanPointerElementSlice 指针元素切片扫描

关键代码变更

isExpandingField() 函数(新增)

func isExpandingField(field any) bool {
    switch v := field.(type) {
    case Raw, *Raw:
        return true
    case string:
        return v == "*" || gstr.HasSuffix(v, ".*")
    default:
        return false
    }
}

设计要点

  • 使用类型开关而非 gconv.String 转换,性能更优
  • COUNT(*) 不会被误判(既不等于 "*",也不以 ".*" 结尾)
  • 支持 *Raw 指针类型

isSingleFieldSpecified() 简化

func (m *Model) isSingleFieldSpecified() bool {
    if len(m.fields) != 1 {
        return false
    }
    return !isExpandingField(m.fields[0])
}

Scan() 方法的 Scanner 分支重构

// Before: 两层嵌套 if + 重复 doStruct 调用
if m.isSingleFieldSpecified() {
    // ... Scanner path ...
}
return m.doStruct(pointer, where...)

// After: 统一 validateSingleFieldSpecified 守卫 + 单一 doStruct 回退
if err := m.validateSingleFieldSpecified(); err != nil {
    return err
}
// ... Scanner path ...
return m.doStruct(pointer, where...)

已知限制

  • []decimal.Decimal[]gtime.Time(Scanner 类型的值切片)不被支持——仅支持 []*decimal.Decimal[]*gtime.Time(指针切片)。这是因为 Scanner 接口在指针接收器上实现,值类型不满足 sql.Scanner 接口。

测试状态

  • database/gdb/ 包全部测试通过
  • util/gconv/ 包全部测试通过
  • internal/reflection/ 包全部测试通过
  • ✅ 代码编译通过(go build ./...

Comment thread database/gdb/gdb_model_select.go
lingcoder pushed a commit that referenced this pull request Aug 17, 2026
…ces (#4848)

Extracted from #4716: these `gconv` bugs already exist on master and are
independent of the Scan-into-basic-type feature.

## Problem

On master:

```go
gconv.Int64(reflect.ValueOf(42))          // 0
gconv.Bool(reflect.ValueOf(false))        // true
gconv.String(reflect.ValueOf(42))         // "<int Value>"

vs := gvar.Vars{gvar.New(1), gvar.New(2)}
var ids []*int64
vs.Scan(&ids)                             // panic: SetInt on ptr Value
```

`Bool`/`Int64`/`Uint64`/`Float*` treat `reflect.Value` as the converted
value. `String()` hits `reflect.Value.String()` and returns the
reflection type name. Slice `Scan` into `[]*T` calls
`SetInt`/`SetString` on a pointer value.

## Fix

- Unwrap interfaceable `reflect.Value` inputs in the basic converters
- Allocate pointer slice elements before assigning scanned values

## Test plan

- [x] `go test -count=1 ./util/gconv/`
- [x] `TestScanReflectValueInput`
- [x] `TestScanPointerElementSlice`

Related: #4716 (feature PR should rebase onto this after merge)
hailaz added 2 commits August 17, 2026 14:10
# Conflicts:
#	util/gconv/gconv_z_unit_scan_basic_types_test.go
FieldsEx is an exclusion list and does not declare a single result
column. Accepting it let Scan reuse Value()/Array(), which return
FirstResultColumn before any column-count check and silently drop
the remaining columns.
@hailaz

hailaz commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

有点问题:FieldsEx 绕过了本 PR 的单字段校验——结果有多列时静默返回第一列,用户拿到残缺数据且收不到任何错误。

表 (id,name,age),数据 {1,alice,30}、{2,bob,40}。FieldsEx("id") 实际剩 name、age 两列。

var s string var names []string

db.Model(t).Fields("name").Scan(&s) // PR 前: err: should type of struct/*struct/[]struct/[]*struct // PR 后: err=nil, s="alice" ✅ 单列,符合预期

db.Model(t).Fields("name","age").Scan(&s) // PR 前: err: should type of struct/*struct/[]struct/[]*struct // PR 后: err: requires exactly 1 field specified via Fields(), but got 2 ✅ 2 列,正确拦截

db.Model(t).FieldsEx("id").Scan(&s) // PR 前: err: should type of struct/*struct/[]struct/[]*struct // PR 后: err=nil, s="alice" ❌ 同样 2 列却没拦,age 被悄悄丢掉

db.Model(t).FieldsEx("id").Scan(&names) // PR 前: err=nil, [{"age":30,"name":"alice"}] // PR 后: err=nil, [alice bob] ❌ 同样 2 列却没拦

根源:validateSingleFieldSpecified 放行 FieldsEx,注释说列数交给 Value()/Array() 查询后校验;但 gdb_core_underlying.go:472 每次查询都会设 FirstResultColumn,Value()(:641)/Array()(:701) 在校验前就 return v, nil 了,那段校验代码根本走不到。

@lingcoder 已按这个反馈改了。

FieldsEx 是排除列表,不是单列声明。原先 validateSingleFieldSpecified() 放行 len(m.fieldsEx) > 0,再指望 Value() / Array() 查完后校验列数;但它们会先走 FirstResultColumn,多列时直接返回第一列,校验根本走不到,所以会静默丢列。

这个 PR 里不再给 FieldsEx 开特例,基础类型 Scan 只认显式单列 Fields()

db.Model(t).Fields("name").Scan(&s)          // ok
db.Model(t).Fields("name", "age").Scan(&s)   // err
db.Model(t).FieldsEx("id").Scan(&s)          // err
db.Model(t).FieldsEx("id").Scan(&names)      // err

Value() / Array() 的既有“取第一列”语义这次没动。对应提交:249d9deaa fix(database/gdb): reject FieldsEx for Scan into basic types

@wanghaolong613

Copy link
Copy Markdown
Contributor Author

有点问题:FieldsEx 绕过了本 PR 的单字段校验——结果有多列时静默返回第一列,用户拿到残缺数据且收不到任何错误。
表 (id,name,age),数据 {1,alice,30}、{2,bob,40}。FieldsEx("id") 实际剩 name、age 两列。
var s string var names []string
db.Model(t).Fields("name").Scan(&s) // PR 前: err: should type of struct/*struct/[]struct/[]*struct // PR 后: err=nil, s="alice" ✅ 单列,符合预期
db.Model(t).Fields("name","age").Scan(&s) // PR 前: err: should type of struct/*struct/[]struct/[]*struct // PR 后: err: requires exactly 1 field specified via Fields(), but got 2 ✅ 2 列,正确拦截
db.Model(t).FieldsEx("id").Scan(&s) // PR 前: err: should type of struct/*struct/[]struct/[]*struct // PR 后: err=nil, s="alice" ❌ 同样 2 列却没拦,age 被悄悄丢掉
db.Model(t).FieldsEx("id").Scan(&names) // PR 前: err=nil, [{"age":30,"name":"alice"}] // PR 后: err=nil, [alice bob] ❌ 同样 2 列却没拦
根源:validateSingleFieldSpecified 放行 FieldsEx,注释说列数交给 Value()/Array() 查询后校验;但 gdb_core_underlying.go:472 每次查询都会设 FirstResultColumn,Value()(:641)/Array()(:701) 在校验前就 return v, nil 了,那段校验代码根本走不到。

@lingcoder 已按这个反馈改了。

FieldsEx 是排除列表,不是单列声明。原先 validateSingleFieldSpecified() 放行 len(m.fieldsEx) > 0,再指望 Value() / Array() 查完后校验列数;但它们会先走 FirstResultColumn,多列时直接返回第一列,校验根本走不到,所以会静默丢列。

这个 PR 里不再给 FieldsEx 开特例,基础类型 Scan 只认显式单列 Fields()

db.Model(t).Fields("name").Scan(&s)          // ok
db.Model(t).Fields("name", "age").Scan(&s)   // err
db.Model(t).FieldsEx("id").Scan(&s)          // err
db.Model(t).FieldsEx("id").Scan(&names)      // err

Value() / Array() 的既有“取第一列”语义这次没动。对应提交:249d9deaa fix(database/gdb): reject FieldsEx for Scan into basic types

我写了点支持 FieldsEx 多列的代码,我能提交吗?

@hailaz

hailaz commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

我写了点支持 FieldsEx 多列的代码,我能提交吗?

@wanghaolong613
感觉提交前可以先讨论,在实际业务中,Fields选中一个字段是不是比FieldsEx条件更好写,所以我觉得应该避免用户使用FieldsEx这么绕的方式来查询单列。
还是说有其他必要的场景需要用到FieldsEx?
@lingcoder 你觉得呢?

@hailaz

hailaz commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

@wanghaolong613 要不你私聊一下强哥,让他把你拉到微信开发群。

@wanghaolong613

Copy link
Copy Markdown
Contributor Author

我觉得也是 ,只有查询的字段比较多的时候才使用 FieldsEx 吧

@wanghaolong613 感觉提交前可以先讨论,在实际业务中,Fields选中一个字段是不是比FieldsEx条件更好写,所以我觉得应该避免用户使用FieldsEx这么绕的方式来查询单列。 还是说有其他必要的场景需要用到FieldsEx? @lingcoder 你觉得呢?

@wanghaolong613

Copy link
Copy Markdown
Contributor Author

@wanghaolong613要不要你私聊一下强哥,让他把你拉到微信开发群。

OK

@lingcoder

Copy link
Copy Markdown
Contributor

在 sqlite 驱动上把 9edefe9(base) 与 249d9de(PR) 跑同一组用例对照,有 4 处想确认。

1. 只实现 sql.Scanner 的类型,从「不报错」变成「报错」,而 Scan 方法两边都没被调用

type OnlyScanner struct{ V string }
func (d *OnlyScanner) Scan(src any) error { d.V = "viaScan:" + fmt.Sprint(src); return nil }

var c OnlyScanner
db.Model(t).Fields("passport").Where("id",1).Scan(&c)
// base:     err = nil,  c = {V:}                                            ← Scan 未被调用
// 249d9de:  err = convert params from ""user_1"" to "map[string]any" failed

value.Scan(pointer) 最终走的是 gconv.Scan,而 util/gconv 全目录没有出现过 sql.Scanner,它认的是 UnmarshalValue/UnmarshalText/UnmarshalJSON。所以用 sql.Scanner 做路由判据、把这类值收进基础分支后,转换层并不认识它。decimal.Decimal/gtime.Time 能通过是因为它们实现了 UnmarshalText/UnmarshalJSON,与 sql.Scanner 无关。文档注释写的是 "scanned through their Scan method"。

顺带一处:slice 分支的 Scanner 判定是 elemType.Implements(...) || originalType.Implements(...),而 originalType 只在元素为指针时才更新,非指针时两者相同、第二个判断不生效;struct 分支用的是 reflect.PointerTo(elemType)

2. 单字段校验数的是 entry 数,不是最终列数(两个方向都偏)

var s string
db.Model(t).Fields("IFNULL(nickname,''), passport").Where("id",2).Scan(&s)   // 实际 2 列
// base:    err = should type of struct/*struct/[]struct/[]*struct
// 249d9de: err = nil, s = "2.02"        ← passport 被静默丢弃

db.Model(t).FieldsEx("id","age").Scan(&s)   // 排除后恰好剩 1 列
// 4b410fc: s = "alice", err = nil
// 249d9de: err = ...requires exactly 1 field..., but got 0

Fields("id","passport") 能正确报 got 2,差别在 gdb_model_utility.go:113default 分支把非纯字段名保留成一个 entry。另外 FieldsEx("id","age") 报的是 but got 0,也未提及 FieldsEx

3. 实现了 sql.Scanner 的多字段 struct 无法再直接 Scan,且 struct 与 slice 分支不一致

type ScannerUser struct{ Id int; Passport string }   // 实现 sql.Scanner
func (u *ScannerUser) Scan(src any) error { return nil }

db.Model(t).Where("id",1).Scan(&u)
// base:    err = nil, u = {Id:1 Passport:user_1}
// 249d9de: err = ...requires exactly 1 field..., but got 0

db.Model(t).Where("id",1).Scan(&us)   // []ScannerUser,同类型同查询
// base 与 249d9de 均为: err = nil, [{Id:1 Passport:user_1}]

slice 分支在校验失败且元素为 struct 时会回落 doStructs,struct 分支没有对应回落。

4. ErrNoRows 语义:NULL 被报成无行;无行时切片反而不报错并清空调用方数据

// 行存在,nickname 为 NULL
db.Model(t).Fields("nickname").Where("id",1).Scan(&s)
// base:    err = should type of struct/...(不支持基础类型)
// 249d9de: err = sql: no rows in result set     ← 与真的没有行无法区分

names := []string{"pre"}
db.Model(t).Fields("passport").Where("id",9999).Scan(&names)
// base:    err = sql: no rows in result set, names = ["pre"]
// 249d9de: err = nil,                        names = []
// 同场景的 []struct 在本 PR 下仍返回 sql.ErrNoRows

回归方面 ./database/gdb/..../internal/reflection/..../util/gconv/... 全绿;.Cache()LimitScan(ptr, where...)*gvar.Var、命名基础类型(type MyID int64,本 PR 修好了)实测无问题。

补充两点:Test_ScanValidateSingleFieldSpecified 直接构造 &Model{fields: ...} 绕开了 Fields(),第 2 条恰好落在这个盲区;shopspring/decimal 加在 contrib/drivers/mysql/go.mod 主 require 块会进入下游依赖图,而结合第 1 条它并未真正验证 sql.Scanner

@lingcoder

Copy link
Copy Markdown
Contributor

在本地沙箱环境跑 mysql:5.7 和 postgres:17 做了逐项 A/B 实测。

问题 1、2 在 mysql 和 pgsql 上的输出逐字相同,是 gdb/gconv 层的问题,与驱动无关。

复现用表(下文所有 demo 共用,pgsql 用等价 DDL):

CREATE TABLE `t` (
  `id` bigint NOT NULL,
  `username` varchar(64) DEFAULT NULL,
  `age` int DEFAULT NULL,
  `balance` decimal(10,2) DEFAULT NULL,
  `state` bool DEFAULT NULL,
  `create_at` datetime DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB;
INSERT INTO `t` VALUES (1,'u1',18,1.01,1,'2020-01-01 00:00:00');
INSERT INTO `t` VALUES (2,'',0,0,0,'2020-01-02 00:00:00');
INSERT INTO `t` VALUES (3,NULL,NULL,NULL,NULL,NULL);

1. 结构体实现 sql.Scanner 时,整行数据静默丢失

converter_struct.go 新增的 ISqlScanner 分支加在了 bindVarToReflectValueWithInterfaceCheck 里,而这个函数的 134/155 两个调用点是 Struct()顶层入口(目标对象本身),不是结构体字段。于是任何实现了 Scan(any) error 的结构体,不再按字段映射,而是被整个 map/Record 塞进它的 Scan

1a. 方法提升 —— 用户根本不知道自己实现了 sql.Scanner

type Entity struct {
    *gtime.Time      // 嵌入即通过方法提升满足 sql.Scanner
    Id       int64
    Username string
}

var e Entity
err := db.Model("t").Where("id", 1).Scan(&e)
master: err=<nil>  id=1  user="u1"
本 PR : err=<nil>  id=0  user=""      ← 静默全空

(*gtime.Time).Scan 有 nil receiver 保护会直接 return nil,所以连 panic 都没有,纯静默。

1b. 显式实现 Scan 的行结构体

type Row struct {
    Id       int64
    Username string
    Raw      string
}
func (r *Row) Scan(v any) error { r.Raw = fmt.Sprintf("%T:%v", v, v); return nil }

var r Row
err := db.Model("t").Where("id", 1).Scan(&r)
master: err=<nil>  {Id:1 User:"u1" Raw:}
本 PR : err=<nil>  {Id:0 User:"" Raw:gdb.Record:map[id:1 username:u1]}

加上 Fields("id","username") 结果一样。

1c. 不经 Model.Scan 也一样 —— Record.Struct() / Result.Structs()

one, _ := db.Model("t").Where("id", 1).One()
var r Row
err := one.Struct(&r)
master: {Id:1 User:"u1" Raw:}
本 PR : {Id:0 User:"" Raw:gdb.Record:map[age:18 balance:1.01 create_at:2020-01-01 00:00:00 id:1 state:1 username:u1]}
all, _ := db.Model("t").All()
var rs []Row
err := all.Structs(&rs)
master: n=3 [{Id:1 User:"u1"} {Id:2 User:""} {Id:3 User:""}]
本 PR : n=3 [{Id:0 User:""} {Id:0 User:""} {Id:0 User:""}]    ← 全空

1d. 影响面不止 ORM —— gconv.Struct / gconv.Structs 同样中招

var r Row
err := gconv.Struct(map[string]any{"Id": 7, "Username": "x"}, &r)
master: err=<nil>  {Id:7 User:"x" Raw:}
本 PR : err=<nil>  {Id:0 User:"" Raw:map[string]interface {}:map[Id:7 Username:x]}
var rs []Row
err := gconv.Structs([]map[string]any{{"Id": 7, "Username": "x"}}, &rs)
master: [{Id:7 User:"x"}]
本 PR : [{Id:0 User:""}]

gconv.Struct/Structsghttp.Request.Parsegcfg().Get().Struct()gjsongvalid 的公共底座 —— 任何请求参数结构体只要意外满足了 sql.Scanner,参数就整体丢失且不报错。

顺带一提,测试里那段注释掉的 // if record, ok := v.(gdb.Record); ok {...} 说明这个现象其实已经撞见过了。


2. []T(T 实现 sql.Scanner)从可用变成硬报错

struct 分支在校验失败时会 fallback 到 doStruct,slice 分支却直接 return err

var rs []Row     // 同 1b 的 Row
err := db.Model("t").Scan(&rs)
master: err=<nil>  n=3  [{Id:1 User:"u1"} {Id:2 User:""} {Id:3 User:""}]
本 PR : err=Scan into basic/scalar-conversion type requires exactly 1 field specified via Fields(), but got 0
err := db.Model("t").Fields("id", "username").Scan(&rs)
master: err=<nil>  n=3
本 PR : err=Scan into basic/scalar-conversion type requires exactly 1 field specified via Fields(), but got 2

标准库类型同样被打死:

var a []time.Time      ; db.Model("t").Scan(&a)   // master: err=<nil> n=3  → 本 PR: 报错 "but got 0"
var b []gtime.Time     ; db.Model("t").Scan(&b)   // master: err=<nil> n=3  → 本 PR: 报错 "but got 0"
var c []sql.NullString ; db.Model("t").Scan(&c)   // master: err=<nil> n=3  → 本 PR: 报错 "but got 0"

struct 分支有 fallback、slice 分支没有,两条路径不对称。


3. NULL 列 / 无匹配行会发两次 SELECT

valueOrNoRowsValue(),拿到 nil 再 One() 去区分「无行」和「NULL 列」。用 Hook.Select 计数:

n := 0
m := db.Model("t").Hook(gdb.HookHandler{
    Select: func(ctx context.Context, in *gdb.HookSelectInput) (gdb.Result, error) {
        n++
        return in.Next(ctx)
    },
})
var v string
err := m.Fields("username").Where("id", ID).Scan(&v)
本 PR  id=1   命中        selects=1
本 PR  id=3   列为 NULL   selects=2   ←
本 PR  id=999 无行        selects=2   ←
本 PR  同一模型扫结构体              selects=1

(master 上标量 Scan 直接报错、根本不发查询,所以这不是回归,是新路径自带的开销。)

除了多一次往返,两次查询之间数据可能变化,「有行但列 NULL」与「无行」的判定不原子;用户注册的 Hook.Select 也会被触发两次。


4. Scan(&time.Time) / &gtime.Time 未指定 Fields 时,从报错变成静默返回某一列的值

校验失败 fallback 到 doStruct 后,gconv 把整行 Record 转成了一个时间值。

var v time.Time
err := db.Model("t").Where("id", 1).Scan(&v)     // 没有 Fields
master: err=reflect.Value.Addr of unaddressable value   v=0001-01-01
本 PR : err=<nil>                                       v=2020-01-01   ← 来源不明
var v gtime.Time
err := db.Model("t").Where("id", 1).Scan(&v)
master: err=reflect.Value.Addr of unaddressable value   v=
本 PR : err=<nil>                                       v=2020-01-01 00:00:00

从「报错」变成「静默给一个来源不明的值」,比原来更危险。


5. []byte 被当成基础类型切片,静默产出垃圾

var b []byte
err := db.Model("t").Fields("username").Where("id", 1).Scan(&b)
master: err=<nil>  b="\x00"
本 PR : err=<nil>  b="\x00"     ← 把 "u1" 转成了一个 uint8

这条两侧一致、不算回归,但本 PR 新增的 basic-slice 判定把 []byte(元素 kind 是 Uint8)也纳进来了,而 Scan(&[]byte) 读 BLOB 是很自然的写法。

对照:不加 Fields 时本 PR 反而会报错,行为不一致。

var b []byte; db.Model("t").Where("id",1).Scan(&b)
master: err=<nil> b="\x00"   →   本 PR: err=Scan into basic/... but got 0

6. []*T 中 NULL 元素的语义变更(全局 gconv 行为)

converter_scan.go 新增的 if srcElem == nil { continue }

var d []*int
err := gconv.Scan([]any{1, nil, 3}, &d)
master: [1 0 3]        (nil 也分配了对象,指向零值)
本 PR : [1 nil 3]      (nil 保持空指针)
var d []*string
gconv.Scan([]any{"a", nil, "c"}, &d)   // master: ["a" "" "c"]  →  本 PR: ["a" nil "c"]
gconv.Scan([]any{nil, nil}, &d)        // master: ["" ""]       →  本 PR: [nil nil]

语义更正确,但这是全局 gconv 行为变更且 breaking —— 现有代码遍历 for _, p := range d { *p } 会开始 panic。PR 描述里没有提到这个变更。


7. ISqlScanner 排在 ISet 之前,同时实现两者的类型改走 Scan

type T struct{ Via string }
func (t *T) Scan(v any) error      { t.Via = fmt.Sprintf("Scan(%v)", v); return nil }
func (t *T) Set(v any) (old any)   { t.Via = fmt.Sprintf("Set(%v)", v); return nil }

var s struct{ F T }
gconv.Struct(map[string]any{"F": "hello"}, &s)
master: F=Set(hello)
本 PR : F=Scan(hello)

int 源、*T 指针字段结果相同。gf 自身不受影响(gvar.Var / gvar.Vars / gjson.JsonScanScan(pointer any, mapping ...map[string]string) 变参签名,不满足 sql.Scanner),但对用户类型是行为变更。


8. 自定义 Scan 返回的 error 被吞掉,替换成误导性的 Convert 报错

type T struct{ V string }
func (t *T) Scan(v any) error {
    if s, ok := v.(string); ok { t.V = s; return nil }
    return fmt.Errorf("T: unsupported source %T", v)   // ← 这个 error 用户永远看不到
}

var s struct{ F T }
err := gconv.Struct(map[string]any{"F": 42}, &s)
master: err=reflect.Value.Convert: value of type int cannot be converted to type T
本 PR : err=reflect.Value.Convert: value of type int cannot be converted to type T   ← 一模一样

本 PR 确实调用了 Scan(同一个类型换成 string 源就能证明:master: err=Convert...本 PR: err=<nil> V="ok"),但 Scan 返回 error 后 bindVarToReflectValue 会去做 .Convert() 并 panic,再被外层 recover 改写成 Convert 报错。结果是新增路径的错误信息不可用。


9. checkTypeIsCommonInterface 没同步加入 ISqlScanner

structcache.go:116 仍然只检查 IUnmarshalText / IUnmarshalJSON / IUnmarshalValue,所以纯 scanner 类型的字段 IsCommonInterface 为 false,走不到 402 的快路径,只能靠 c.Struct 失败 →Convert panic →外层 recover 兜住。

这条没有行为差异(功能是通的,上面 1a/1b 的字段级用例都正常),是实现质量问题:每个这样的字段每次转换都要吃一次 panic/recover。


10. 查询 0 行时切片由 nil 变成非 nil 空切片

var v []string
err := db.Model("t").Fields("id").Where("id", 999).Scan(&v)
master: err=<nil>  len=0  v==nil → true
本 PR : err=<nil>  len=0  v==nil → false

调用方 if v == nil 的判断会变。


11. isExpandingFieldStr 对引号内的逗号误判

var v string
db.Model("t").Fields("concat(username,'x')").Where("id",1).Scan(&v)  // 本 PR: err=<nil> v="u1x"   ✅
db.Model("t").Fields("'a,b'").Where("id",1).Scan(&v)                 // 本 PR: err=... does not support expanding field 'a,b'

括号内的逗号处理正确(Raw("COUNT(DISTINCT \id`,`username`)")` 也没问题),但引号里的逗号会被当成多列拒绝。属新功能的边界限制。


12. Scan 新注释与实测行为不符

注释写的是 "leave the destination at its zero value",实际是保持目标变量原值不动

v := "PRESET"
err := db.Model("t").Fields("username").Where("id", 3).Scan(&v)   // id=3 该列为 NULL
master: err=element of parameter "pointer" ...   v="PRESET"
本 PR : err=<nil>                                v="PRESET"   ← 没有被置零

*string 目标同理(v 预置非 nil 时扫 NULL 仍保留原值)。行为本身与 master 上结构体扫 NULL 行一致,属既有语义,是新注释的描述与之不符。


13. 测试自身的复制粘贴 bug,createTimes2 实际未被断言

[]*time.Time[]*gtime.Time 两段里,断言对象写成了上一个 []time.Time 变量 createTimes,而不是本段要验证的 createTimes2

var createTimes []time.Time                                    // ← 上一段的变量
err = db.Model(table).Fields("create_at").Scan(&createTimes)
t.AssertNil(err)
t.Assert(len(createTimes), 3)
t.Assert(createTimes[0].Format("2006-01-02 15:04:05"), "2020-01-01 00:00:00")
t.Assert(createTimes[1].Format("2006-01-02 15:04:05"), "2020-01-02 00:00:00")
t.Assert(createTimes[2].IsZero(), true)

var createTimes2 []*time.Time                                  // ← 本段要验证的变量
err = db.Model(table).Fields("create_at").Scan(&createTimes2)
t.AssertNil(err)
t.Assert(len(createTimes2), 3)
t.Assert(createTimes[0].Format("2006-01-02 15:04:05"), "2020-01-01 00:00:00")  // ← 又是 createTimes
t.Assert(createTimes[1].Format("2006-01-02 15:04:05"), "2020-01-02 00:00:00")  // ← 又是 createTimes
t.AssertNil(createTimes[2])                                                     // ← 又是 createTimes

[]*gtime.Time 那段(createTimes / createTimes2 同名复用)是完全一样的错误。

所以 createTimes2 除了 len 之外完全没有被验证 —— 也就是说 []*time.Time / []*gtime.Time 这两个目标类型的取值正确性实际上没有测到。

最后一行 t.AssertNil(createTimes[2]) 传的是零值 time.Time(不是指针),本该失败却通过了,原因是:

gtest.AssertNil 对非 nil 值会退化成 Assert(value, nil),而 gtest.Assert 是比较 gconv.String() 的结果:

empty.IsNil(time.Time{})    // false —— Struct kind,不算 nil
gconv.String(time.Time{})   // ""    (实测,gtime.Time{} 同样是 "")
gconv.String(nil)           // ""

两边都是空串,断言就这么白过了。


其中 1、2 两条是回归:master 上能正常工作的代码,在本 PR 上会静默丢失整行数据或直接报错,且现有测试套件全绿、覆盖不到。

@wanghaolong613

Copy link
Copy Markdown
Contributor Author

支持 sql.Scanner 接口问题太多了,我已 revert 相关提交。

@lingcoder lingcoder left a comment

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.

在本地沙箱环境跑 mysql:5.7 和 postgres:17 做了逐项 A/B 实测(head 33ff823d vs master d4ed8514)。

上一版里 gconv 的改动已全部撤销,之前报的整行数据丢失、[]scanner 硬报错、[]*T 的 nil 语义变更、NULL/无行的双查询这几条都不复现了。下面是这一版仍在的和新引入的问题。

复现用表(下文所有 demo 共用,pgsql 用等价 DDL):

CREATE TABLE `t` (
  `id` bigint NOT NULL,
  `username` varchar(64) DEFAULT NULL,
  `age` int DEFAULT NULL,
  `balance` decimal(10,2) DEFAULT NULL,
  `state` bool DEFAULT NULL,
  `payload` blob,
  `create_at` datetime DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB;
INSERT INTO `t` VALUES (1,'u1',18,1.01,1,x'00FF10','2020-01-01 00:00:00');
INSERT INTO `t` VALUES (2,'',0,0,0,x'0102','2020-01-02 00:00:00');
INSERT INTO `t` VALUES (3,NULL,NULL,NULL,NULL,NULL,NULL);   -- 行存在,各列为 NULL

1. 列值为 NULL 时被报成 sql.ErrNoRows,而这一行是存在的

新逻辑用目标变量是不是指针来区分「无行」和「列为 NULL」:

value, err := model.Value()
if value == nil {
    if !empty.IsNil(pointer, true) {
        return sql.ErrNoRows
    }
    return nil
}

Value() 返回 nil 的两种原因它分不开,而 empty.IsNil(pointer, true)*string/*int/*bool 这类指向非指针类型的指针恒为 false,所以实际判据只是「目标是不是指针类型」,跟有没有行无关。

// id=3 这一行存在,只是 username 列为 NULL
var v string
err := db.Model("t").Fields("username").Where("id", 3).Scan(&v)
master: err=element of parameter "pointer" ...(旧版不支持标量目标)
本 PR : err=sql: no rows in result set        ← 行是存在的

四种组合实测,两两撞车,谁也分不出来:

id=3   列为 NULL   非指针目标 &string   →  err=sql: no rows in result set   v=""
id=999 无行        非指针目标 &string   →  err=sql: no rows in result set   v=""
id=3   列为 NULL   指针目标   &*string  →  err=<nil>                        v=nil
id=999 无行        指针目标   &*string  →  err=<nil>                        v=nil

&int / &bool 同样:

var a int  ; db.Model("t").Fields("age").Where("id",3).Scan(&a)    // 本 PR: err=sql: no rows in result set
var b bool ; db.Model("t").Fields("state").Where("id",3).Scan(&b)  // 本 PR: err=sql: no rows in result set

对照:列有值(哪怕是空串 / 零值)时正常。

var v string; db.Model("t").Fields("username").Where("id",2).Scan(&v)  // 本 PR: err=<nil> v=""
var a int   ; db.Model("t").Fields("age").Where("id",2).Scan(&a)       // 本 PR: err=<nil> a=0

另外 Scan 的注释写的是 "returns sql.ErrNoRows if the given parameter pointer pointed to a variable that has default value and there's no record",但预置过值的变量同样返回 ErrNoRows:

v := "PRESET"
err := db.Model("t").Fields("username").Where("id", 3).Scan(&v)
// 本 PR: err=sql: no rows in result set   v="PRESET"(未被改写)

mysql 和 pgsql 输出一致。作者新增的 3977.sql 里被扫的列没有 NULL 行,所以自带测试覆盖不到这条。


2. db.Raw(...) 上用不了新功能,[]T 还从无错变报错

raw SQL 构造出来的 Model 没有 m.fieldsvalidateSingleFieldSpecified 永远过不了。

var v []int64
err := db.Raw("SELECT id FROM t").Scan(&v)
master: err=<nil>  v=[0 0 0]
本 PR : err=Scan into basic type requires exactly 1 field specified via Fields(), but got 0

最自然的标量写法也不可用:

var n int
err := db.Raw("SELECT count(*) FROM t").Scan(&n)
// 本 PR: err=Scan into basic type requires exactly 1 field specified via Fields(), but got 0

唯一能过校验的写法,是在 raw SQL 上再声明一个根本不参与建 SQL 的 Fields

db.Raw("SELECT id FROM t").Fields("id").Scan(&v)   // 本 PR: err=<nil> v=[1 2 3]

pgsql 同样。


3. decimal.Decimal 这类 sql.Scanner 类型没有支持,但 PR 仍认领 #3977

#3977 的案例 1、2 是:

var balance decimal.Decimal
err := db.Model("user").Fields("balance").Where("id", 1).Scan(&balance)

var totalBalance decimal.Decimal
err := db.Model("user").FieldSum("balance").WhereIn("id", []int64{1,2,3,4,5}).Scan(&totalBalance)

用等价的自定义 sql.Scanner 类型实测,这一版和 master 一模一样,值进不来:

type Col struct{ S string }
func (c *Col) Scan(v any) error { c.S = fmt.Sprintf("%v", v); return nil }

var c Col
err := db.Model("t").Fields("username").Where("id", 1).Scan(&c)
master: err=<nil>  c=Col()      ← 空
本 PR : err=<nil>  c=Col()      ← 同样是空

标准库和 gf 自带的同类目标也都没有变化:

var a sql.NullString ; db.Model("t").Fields("balance").Where("id",1).Scan(&a)
// 两侧均为: {String: Valid:false}

var b gtime.Time     ; db.Model("t").Fields("create_at").Where("id",1).Scan(&b)
var c time.Time      ; db.Model("t").Fields("create_at").Where("id",1).Scan(&c)
// 两侧均为: err=reflect.Value.Addr of unaddressable value

上一版 PR 里的 isScalarConversionType / knownScalarStructTypes 覆盖了这些,这一版删掉了,shopspring/decimal 的测试用例也一并删了。当前 Test_Issue3977 只测 string / float64 / int / bool 四种。

PR 描述仍写着 fix #3977,合并后 issue 会被自动关闭,但案例 1、2 没有修。


4. []byte 被当成基础类型切片,静默产出垃圾

var b []byte
err := db.Model("t").Fields("username").Where("id", 1).Scan(&b)
master: err=<nil>  b="\x00"
本 PR : err=<nil>  b="\x00"     ← 把 "u1" 转成了一个 uint8

真二进制列同样只拿到 1 个字节:

// payload 列为 blob,值 x'00FF10'
var b []byte
db.Model("t").Fields("payload").Where("id",1).Scan(&b)
// master / 本 PR 均为: b=00 len=1   (期望 3 字节)

这两条两侧一致、不算回归,但本 PR 的 basic-slice 判定把 []byte(元素 kind 是 Uint8)也纳进了新路径,而 Scan(&[]byte) 读 BLOB 是很自然的写法。

不加 Fields 时行为还不一致:

var b []byte; db.Model("t").Where("id",1).Scan(&b)
master: err=<nil> b="\x00"   →   本 PR: err=Scan into basic type ... but got 0

5. 查询 0 行时切片由 nil 变成非 nil 空切片

var v []string
err := db.Model("t").Fields("username").Where("id", 999).Scan(&v)
master: err=<nil>  len=0  v==nil → true
本 PR : err=<nil>  len=0  v==nil → false

调用方 if v == nil 的判断会变。


6. isExpandingFieldStr 对引号内的逗号误判

var v string
db.Model("t").Fields("concat(username,'x')").Where("id",1).Scan(&v)  // 本 PR: err=<nil> v="u1x"
db.Model("t").Fields("'a,b'").Where("id",1).Scan(&v)                 // 本 PR: err=... does not support expanding field 'a,b'

括号内的逗号处理正确(Raw("COUNT(DISTINCT \id`,`username`)")` 也没问题),引号里的逗号会被当成多列拒绝。


其中 1 是这一版新引入的:行存在但列为 NULL 时返回 sql.ErrNoRows,且「无行」与「列为 NULL」在任何目标类型下都无法区分。

@wanghaolong613

Copy link
Copy Markdown
Contributor Author
  1. 基本类型(basic types)自动处理 string、int、float64、bool 等 Go 基本类型,以及它们的指针(*string、*int)和 slice([]string、[]*int)版本。

  2. 单字段 struct 类型去掉 sql.Scanner 接口判断,改成注册制
    标准库比如 time.Time、框架自身 gtime.Time 等 struct 类型,不再通过 sql.Scanner 接口自动检测,而是通过注册表显式注册:

框架预注册:time.Time、gtime.Time 等常见类型

用户注册: 第三方库 decimal.Decimal 和 用户自定义类型, 用户通过 RegisterScalarType() 全局注册,或单次查询指定 比如 db.Model(table).Fields("username").AsScalar().Scan(&user)
这样方案是否可行

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

database/gdb: Scan, Array, Value deserialization enhancement

4 participants