enhance: [GoSDK] support pointer types for nullable columns in row-based API (#47712)

Related to #47711

Enable Go pointer struct fields (*string, *int32, etc.) to represent
nullable columns in the row-based data processing path. A nil pointer
maps to a NULL value in Milvus; a non-nil pointer dereferences to the
actual value.

Changes:
- ParseSchema: pointer fields auto-infer Nullable=true (PK excluded)
- AnyToColumns: use reflect.Value.IsNil() to call AppendNull for nil
pointers, Elem().Interface() for non-nil — bypasses Go's typed-nil
problem where (*T)(nil) as interface{} is != nil
- SetField: wrap/unwrap pointer values during field assignment
- fillData/fillPKEntry: check IsNull() on columns and set nil pointers
for null values, wrap non-null values in reflect.New pointers
- Dynamic field path: same nil/deref logic for pointer fields going into
the JSON dynamic column

Signed-off-by: Congqi Xia <congqi.xia@zilliz.com>
This commit is contained in:
congqixia
2026-02-10 18:48:42 +08:00
committed by GitHub
parent ccfdfa93fe
commit 8e8e793c2a
6 changed files with 366 additions and 10 deletions
+34 -6
View File
@@ -148,7 +148,14 @@ func (sr *ResultSet) fillPKEntry(receiver any) (err error) {
if err != nil {
return err
}
row.Field(candi).Set(reflect.ValueOf(val))
field := row.Field(candi)
if field.Kind() == reflect.Ptr {
ptr := reflect.New(field.Type().Elem())
ptr.Elem().Set(reflect.ValueOf(val))
field.Set(ptr)
} else {
field.Set(reflect.ValueOf(val))
}
}
rr.Set(rv)
default:
@@ -227,12 +234,33 @@ func (ds DataSet) fillData(data reflect.Value, dataType reflect.Type, idx int) e
// `strict` mode could be added in the future to return error if any column missing
continue
}
val, err := ds[i].Get(idx)
if err != nil {
return err
field := data.Field(fidx)
fieldType := dataType.Field(fidx).Type
if fieldType.Kind() == reflect.Ptr {
isNull, err := ds[i].IsNull(idx)
if err != nil {
return err
}
if isNull {
field.Set(reflect.Zero(fieldType))
continue
}
val, err := ds[i].Get(idx)
if err != nil {
return err
}
ptr := reflect.New(fieldType.Elem())
ptr.Elem().Set(reflect.ValueOf(val))
field.Set(ptr)
} else {
val, err := ds[i].Get(idx)
if err != nil {
return err
}
field.Set(reflect.ValueOf(val))
}
// TODO check datatype, return error here instead of reflect panicking & recover
data.Field(fidx).Set(reflect.ValueOf(val))
}
return nil
}
+84
View File
@@ -140,6 +140,90 @@ func (s *ResultSetSuite) TestSearchResultUnmarshal() {
s.Error(err)
}
func (s *ResultSetSuite) TestResultsetUnmarshalNullablePointer() {
type NullableData struct {
ID int64 `milvus:"name:id"`
Name *string `milvus:"name:name"`
Age *int32 `milvus:"name:age"`
}
// Create a nullable string column with mixed null/non-null values
nameCol := column.NewColumnVarChar("name", nil)
nameCol.SetNullable(true)
_ = nameCol.AppendValue("alice")
_ = nameCol.AppendNull()
_ = nameCol.AppendValue("charlie")
// Create a nullable int32 column
ageCol := column.NewColumnInt32("age", nil)
ageCol.SetNullable(true)
_ = ageCol.AppendValue(int32(30))
_ = ageCol.AppendValue(int32(25))
_ = ageCol.AppendNull()
rs := DataSet([]column.Column{
column.NewColumnInt64("id", []int64{1, 2, 3}),
nameCol,
ageCol,
})
var receiver []*NullableData
err := rs.Unmarshal(&receiver)
s.NoError(err)
s.Require().Len(receiver, 3)
// Row 0: Name="alice", Age=30
s.Require().NotNil(receiver[0].Name)
s.Equal("alice", *receiver[0].Name)
s.Require().NotNil(receiver[0].Age)
s.Equal(int32(30), *receiver[0].Age)
// Row 1: Name=nil, Age=25
s.Nil(receiver[1].Name)
s.Require().NotNil(receiver[1].Age)
s.Equal(int32(25), *receiver[1].Age)
// Row 2: Name="charlie", Age=nil
s.Require().NotNil(receiver[2].Name)
s.Equal("charlie", *receiver[2].Name)
s.Nil(receiver[2].Age)
}
func (s *ResultSetSuite) TestSearchResultUnmarshalPointerPK() {
type PtrPKData struct {
A *int64 `milvus:"name:id"`
V []float32 `milvus:"name:vector"`
}
idData := []int64{1, 2, 3}
vectorData := [][]float32{
{0.1, 0.2},
{0.1, 0.2},
{0.1, 0.2},
}
sr := ResultSet{
sch: entity.NewSchema().
WithField(entity.NewField().WithName("id").WithIsPrimaryKey(true).WithDataType(entity.FieldTypeInt64)).
WithField(entity.NewField().WithName("vector").WithDim(2).WithDataType(entity.FieldTypeFloatVector)),
IDs: column.NewColumnInt64("id", idData),
Fields: DataSet([]column.Column{
column.NewColumnFloatVector("vector", 2, vectorData),
}),
}
var receiver []*PtrPKData
err := sr.Unmarshal(&receiver)
s.NoError(err)
s.Require().Len(receiver, 3)
for idx, row := range receiver {
s.Require().NotNil(row.A)
s.Equal(idData[idx], *row.A)
s.Equal(vectorData[idx], row.V)
}
}
func TestResults(t *testing.T) {
suite.Run(t, new(ResultSetSuite))
}
+32 -3
View File
@@ -138,7 +138,15 @@ func AnyToColumns(rows []interface{}, keepPkField bool, schemas ...*entity.Schem
}
nameColumns[fieldName] = column
err = column.AppendValue(candi.v.Interface())
if candi.isPtr {
if candi.v.IsNil() {
err = column.AppendNull()
} else {
err = column.AppendValue(candi.v.Elem().Interface())
}
} else {
err = column.AppendValue(candi.v.Interface())
}
if err != nil {
return nil, err
}
@@ -148,7 +156,15 @@ func AnyToColumns(rows []interface{}, keepPkField bool, schemas ...*entity.Schem
if isDynamic {
m := make(map[string]interface{})
for name, candi := range set {
m[name] = candi.v.Interface()
if candi.isPtr {
if candi.v.IsNil() {
m[name] = nil
} else {
m[name] = candi.v.Elem().Interface()
}
} else {
m[name] = candi.v.Interface()
}
}
bs, err := json.Marshal(m)
if err != nil {
@@ -312,7 +328,17 @@ func SetField(receiver any, fieldName string, value any) error {
}
if candidate.v.CanSet() {
candidate.v.Set(reflect.ValueOf(value))
if candidate.isPtr {
if value == nil {
candidate.v.Set(reflect.Zero(candidate.v.Type()))
} else {
ptr := reflect.New(candidate.v.Type().Elem())
ptr.Elem().Set(reflect.ValueOf(value))
candidate.v.Set(ptr)
}
} else {
candidate.v.Set(reflect.ValueOf(value))
}
}
return nil
@@ -322,6 +348,7 @@ type fieldCandi struct {
name string
v reflect.Value
options map[string]string
isPtr bool
}
func reflectValueCandi(v reflect.Value) (map[string]fieldCandi, error) {
@@ -400,6 +427,7 @@ func getStructReflectCandidates(v reflect.Value) (map[string]fieldCandi, error)
}
v := v.Field(i)
isPtr := v.Kind() == reflect.Ptr
if v.Kind() == reflect.Array {
v = v.Slice(0, v.Len())
}
@@ -408,6 +436,7 @@ func getStructReflectCandidates(v reflect.Value) (map[string]fieldCandi, error)
name: name,
v: v,
options: settings,
isPtr: isPtr,
}
}
+155
View File
@@ -245,6 +245,161 @@ func (s *RowsSuite) TestReflectValueCandi() {
}
}
func (s *RowsSuite) TestNullablePointerColumns() {
s.Run("nil_pointer_appends_null", func() {
type NullableRow struct {
ID int64 `milvus:"primary_key"`
Name *string `milvus:"max_length:256"`
Age *int32
Vector []float32 `milvus:"dim:16"`
}
columns, err := AnyToColumns([]any{&NullableRow{
ID: 1,
Name: nil,
Age: nil,
Vector: make([]float32, 16),
}}, false)
s.NoError(err)
for _, col := range columns {
if col.Name() == "Name" || col.Name() == "Age" {
s.True(col.Nullable(), "column %s should be nullable", col.Name())
isNull, err := col.IsNull(0)
s.NoError(err)
s.True(isNull, "column %s should have null at index 0", col.Name())
}
}
})
s.Run("non_nil_pointer_appends_value", func() {
type NullableRow struct {
ID int64 `milvus:"primary_key"`
Name *string `milvus:"max_length:256"`
Age *int32
Vector []float32 `milvus:"dim:16"`
}
name := "test"
age := int32(25)
columns, err := AnyToColumns([]any{&NullableRow{
ID: 1,
Name: &name,
Age: &age,
Vector: make([]float32, 16),
}}, false)
s.NoError(err)
for _, col := range columns {
switch col.Name() {
case "Name":
s.True(col.Nullable())
isNull, err := col.IsNull(0)
s.NoError(err)
s.False(isNull)
val, err := col.Get(0)
s.NoError(err)
s.Equal("test", val)
case "Age":
s.True(col.Nullable())
isNull, err := col.IsNull(0)
s.NoError(err)
s.False(isNull)
val, err := col.Get(0)
s.NoError(err)
s.Equal(int32(25), val)
}
}
})
s.Run("mixed_nil_and_values", func() {
type NullableRow struct {
ID int64 `milvus:"primary_key"`
Name *string `milvus:"max_length:256"`
Vector []float32 `milvus:"dim:16"`
}
name := "hello"
columns, err := AnyToColumns([]any{
&NullableRow{ID: 1, Name: &name, Vector: make([]float32, 16)},
&NullableRow{ID: 2, Name: nil, Vector: make([]float32, 16)},
}, false)
s.NoError(err)
for _, col := range columns {
if col.Name() == "Name" {
s.True(col.Nullable())
isNull0, err := col.IsNull(0)
s.NoError(err)
s.False(isNull0)
val, err := col.Get(0)
s.NoError(err)
s.Equal("hello", val)
isNull1, err := col.IsNull(1)
s.NoError(err)
s.True(isNull1)
}
}
})
}
func (s *RowsSuite) TestSetFieldPointer() {
s.Run("set_pointer_field_with_value", func() {
type PtrStruct struct {
Name *string
}
row := &PtrStruct{}
err := SetField(row, "Name", "hello")
s.NoError(err)
s.Require().NotNil(row.Name)
s.Equal("hello", *row.Name)
})
s.Run("set_pointer_field_with_nil", func() {
type PtrStruct struct {
Name *string
}
name := "old"
row := &PtrStruct{Name: &name}
err := SetField(row, "Name", nil)
s.NoError(err)
s.Nil(row.Name)
})
s.Run("set_non_pointer_field", func() {
type RegularStruct struct {
Name string
}
row := &RegularStruct{}
err := SetField(row, "Name", "hello")
s.NoError(err)
s.Equal("hello", row.Name)
})
}
func (s *RowsSuite) TestReflectValueCandiPointer() {
s.Run("pointer_field_isPtr", func() {
type PtrStruct struct {
Name *string
Value int64
}
name := "test"
v := reflect.ValueOf(PtrStruct{Name: &name, Value: 42})
result, err := reflectValueCandi(v)
s.NoError(err)
nameCandi, ok := result["Name"]
s.True(ok)
s.True(nameCandi.isPtr)
valueCandi, ok := result["Value"]
s.True(ok)
s.False(valueCandi.isPtr)
})
}
func TestRows(t *testing.T) {
suite.Run(t, new(RowsSuite))
}
+4 -1
View File
@@ -65,8 +65,10 @@ func ParseSchema(r interface{}) (*entity.Schema, error) {
Name: f.Name,
}
ft := f.Type
if f.Type.Kind() == reflect.Ptr {
isPtr := f.Type.Kind() == reflect.Ptr
if isPtr {
ft = ft.Elem()
field.Nullable = true
}
fv := reflect.New(ft)
tag := f.Tag.Get(MilvusTag)
@@ -76,6 +78,7 @@ func ParseSchema(r interface{}) (*entity.Schema, error) {
tagSettings := ParseTagSetting(tag, MilvusTagSep)
if _, has := tagSettings[MilvusPrimaryKey]; has {
field.PrimaryKey = true
field.Nullable = false
}
if _, has := tagSettings[MilvusAutoID]; has {
field.AutoID = true
+57
View File
@@ -210,4 +210,61 @@ func TestParseSchema(t *testing.T) {
assert.True(t, i64f)
assert.True(t, vecf)
})
t.Run("nullable_pointer_fields", func(t *testing.T) {
type NullableStruct struct {
ID int64 `milvus:"primary_key"`
Name *string `milvus:"max_length:256"`
Age *int32
Score *float64
Active *bool
Vector []float32 `milvus:"dim:16"`
}
sch, err := ParseSchema(&NullableStruct{})
assert.NoError(t, err)
assert.NotNil(t, sch)
fieldMap := make(map[string]*entity.Field)
for _, f := range sch.Fields {
fieldMap[f.Name] = f
}
// PK field should NOT be nullable even if it were a pointer (it's not here)
assert.False(t, fieldMap["ID"].Nullable)
// Pointer fields should be nullable
assert.True(t, fieldMap["Name"].Nullable)
assert.Equal(t, entity.FieldTypeVarChar, fieldMap["Name"].DataType)
assert.True(t, fieldMap["Age"].Nullable)
assert.Equal(t, entity.FieldTypeInt32, fieldMap["Age"].DataType)
assert.True(t, fieldMap["Score"].Nullable)
assert.Equal(t, entity.FieldTypeDouble, fieldMap["Score"].DataType)
assert.True(t, fieldMap["Active"].Nullable)
assert.Equal(t, entity.FieldTypeBool, fieldMap["Active"].DataType)
// Non-pointer fields should NOT be nullable
assert.False(t, fieldMap["Vector"].Nullable)
})
t.Run("pointer_pk_not_nullable", func(t *testing.T) {
type PtrPKStruct struct {
ID *int64 `milvus:"primary_key"`
Vector []float32 `milvus:"dim:16"`
}
sch, err := ParseSchema(&PtrPKStruct{})
assert.NoError(t, err)
assert.NotNil(t, sch)
for _, f := range sch.Fields {
if f.Name == "ID" {
assert.True(t, f.PrimaryKey)
assert.False(t, f.Nullable, "PK field should not be nullable")
}
}
})
}