Version
github.com/gogf/gf/v2 v2.9.0
github.com/gogf/gf/contrib/drivers/mysql/v2 v2.8.3
- go 1.23, linux/amd64
- (Confirmed the relevant code path is unchanged on
master as of this writing.)
What happens
db.Model(...).Scan(&rows) / db.Raw(sql).Scan(&rows) where rows is []map[string]interface{}
returns a slice of the correct length but every element is an empty map, and no error
is returned. Switching the destination to a typed struct slice, or reading via .All() /
.All().List(), works correctly against the very same query.
Minimal, self-contained reproduction (no database needed)
The bug is in the result→slice conversion, so it reproduces against a hand-built gdb.Result:
package main
import (
"fmt"
"github.com/gogf/gf/v2/container/gvar"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/util/gconv"
)
func main() {
// A gdb.Result is []gdb.Record, i.e. []map[string]*gvar.Var — exactly what a driver returns.
res := gdb.Result{
gdb.Record{"regionId": gvar.New(1)},
gdb.Record{"regionId": gvar.New(2)},
gdb.Record{"regionId": gvar.New(3)},
}
// (1) Result.Structs into *[]map — what Model.Scan(&[]map) dispatches to.
var viaStructs []map[string]interface{}
_ = res.Structs(&viaStructs)
fmt.Printf("Result.Structs : len=%d first=%v\n", len(viaStructs), first(viaStructs))
// => len=3 first=map[] <-- BUG: 3 empty maps, no error
// (2) gconv.Scan on the same source into *[]map — works.
var viaScan []map[string]interface{}
_ = gconv.Scan(res, &viaScan)
fmt.Printf("gconv.Scan : len=%d first=%v\n", len(viaScan), first(viaScan))
// => len=3 first=map[regionId:1]
// (3) Result.List() — works.
fmt.Printf("Result.List() : len=%d first=%v\n", len(res.List()), first(res.List()))
// => len=3 first=map[regionId:1]
}
func first(s []map[string]interface{}) interface{} {
if len(s) == 0 {
return nil
}
return s[0]
}
Output:
Result.Structs : len=3 first=map[]
gconv.Scan : len=3 first=map[regionId:1]
Result.List() : len=3 first=map[regionId:1]
Root cause
Result.Structs (database/gdb/gdb_type_result.go) calls converter.Structs(...) directly:
return converter.Structs(r, pointer, gconv.StructsOption{
SliceOption: gconv.SliceOption{ContinueOnError: true},
StructOption: gconv.StructOption{PriorityTag: OrmTagForStruct, ContinueOnError: true},
})
In util/gconv/internal/converter/converter_structs.go, Structs only special-cases whether the
destination element is a pointer; otherwise it unconditionally treats it as a struct:
reflectElemArray := reflect.MakeSlice(pointerRv.Type().Elem(), len(paramsList), len(paramsList))
itemType := reflectElemArray.Index(0).Type() // = map[string]interface{}
itemTypeKind := itemType.Kind() // = reflect.Map
...
if itemTypeKind == reflect.Ptr {
// pointer element
} else {
// "Struct element" — taken for reflect.Map too
...
if err = c.Struct(paramsList[i], tempReflectValue, structsOption.StructOption); err != nil {
return err
}
...
}
So each map element is fed to c.Struct. In converter_struct.go, c.Struct fetches struct
metadata:
cachedStructInfo := c.internalConverter.GetCachedStructInfo(pointerElemReflectValue.Type(), ...)
if cachedStructInfo == nil {
return nil // <-- silent no-op
}
and GetCachedStructInfo (util/gconv/internal/structcache/structcache_cached.go) bails for any
non-struct type:
func (cf *Converter) GetCachedStructInfo(structType reflect.Type, priorityTag string) *CachedStructInfo {
if structType.Kind() != reflect.Struct {
return nil
}
...
}
Net effect: the pre-allocated slice (correct length) is returned with each map left at its zero
value (nil/empty), and err == nil.
By contrast, gconv.Scan → converter.Scan does have the map-slice branch
(converter_scan.go, sliceElemKind == reflect.Map → MapToMaps), which is why
gconv.Scan / gconv.Structs and Result.List() all work — only Result.Structs
(and therefore Model.Scan(&[]map...)) is affected.
Impact
Model.Scan(&[]map[string]interface{}) and Raw(...).Scan(&[]map[string]interface{}) are common
idioms for ad-hoc/untyped queries. The failure is silent (no error, plausible length), so callers
see "the table returned N rows but every field is empty" and easily misattribute it to the driver
or the data.
Suggested fix (either is fine)
- Make it work: in
converter.Structs, when itemTypeKind == reflect.Map, route through the
same map-slice path that converter.Scan uses (MapToMaps) instead of the struct path. This
makes Result.Structs(&[]map) behave like Result.List().
- At minimum, fail loudly: when the destination element is neither struct nor pointer-to-struct
(e.g. a map), return a clear error rather than silently producing empty elements.
Happy to send a PR for whichever direction maintainers prefer.
Version
github.com/gogf/gf/v2 v2.9.0github.com/gogf/gf/contrib/drivers/mysql/v2 v2.8.3masteras of this writing.)What happens
db.Model(...).Scan(&rows)/db.Raw(sql).Scan(&rows)whererowsis[]map[string]interface{}returns a slice of the correct length but every element is an empty map, and no error
is returned. Switching the destination to a typed struct slice, or reading via
.All()/.All().List(), works correctly against the very same query.Minimal, self-contained reproduction (no database needed)
The bug is in the result→slice conversion, so it reproduces against a hand-built
gdb.Result:Output:
Root cause
Result.Structs(database/gdb/gdb_type_result.go) callsconverter.Structs(...)directly:In
util/gconv/internal/converter/converter_structs.go,Structsonly special-cases whether thedestination element is a pointer; otherwise it unconditionally treats it as a struct:
So each
mapelement is fed toc.Struct. Inconverter_struct.go,c.Structfetches structmetadata:
and
GetCachedStructInfo(util/gconv/internal/structcache/structcache_cached.go) bails for anynon-struct type:
Net effect: the pre-allocated slice (correct length) is returned with each map left at its zero
value (
nil/empty), anderr == nil.By contrast,
gconv.Scan→converter.Scandoes have the map-slice branch(
converter_scan.go,sliceElemKind == reflect.Map→MapToMaps), which is whygconv.Scan/gconv.StructsandResult.List()all work — onlyResult.Structs(and therefore
Model.Scan(&[]map...)) is affected.Impact
Model.Scan(&[]map[string]interface{})andRaw(...).Scan(&[]map[string]interface{})are commonidioms for ad-hoc/untyped queries. The failure is silent (no error, plausible length), so callers
see "the table returned N rows but every field is empty" and easily misattribute it to the driver
or the data.
Suggested fix (either is fine)
converter.Structs, whenitemTypeKind == reflect.Map, route through thesame map-slice path that
converter.Scanuses (MapToMaps) instead of the struct path. Thismakes
Result.Structs(&[]map)behave likeResult.List().(e.g. a map), return a clear error rather than silently producing empty elements.
Happy to send a PR for whichever direction maintainers prefer.