Skip to content

gdb: Result.Structs / Model.Scan into *[]map[string]interface{} silently returns empty maps instead of data or error #4787

Description

@pdw03

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.Scanconverter.Scan does have the map-slice branch
(converter_scan.go, sliceElemKind == reflect.MapMapToMaps), 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)

  1. 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().
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions