1
0

field inspection

This commit is contained in:
Arpad Ryszka 2025-08-28 05:04:06 +02:00
parent 88bc6f6ab7
commit 613cbf94f0
13 changed files with 761 additions and 17 deletions

195
field.go Normal file
View File

@ -0,0 +1,195 @@
package bind
import (
"fmt"
"github.com/iancoleman/strcase"
"reflect"
"unicode"
)
func exported(name string) bool {
return unicode.IsUpper([]rune(name)[0])
}
func fields(t reflect.Type) []Field {
t = unpackType(t, pointer)
list := t.Kind() == reflect.Slice
t = unpackType(t, pointer|slice)
if t.Kind() != reflect.Struct {
return nil
}
var f []Field
for i := 0; i < t.NumField(); i++ {
tfi := t.Field(i)
if !exported(tfi.Name) && !tfi.Anonymous {
continue
}
if acceptsScalar(tfi.Type) {
var fi Field
ft := unpackType(tfi.Type, pointer|slice)
fi.isBool = ft.Kind() == reflect.Bool
fi.list = acceptsList(tfi.Type)
fi.name = strcase.ToKebab(tfi.Name)
fi.path = []string{tfi.Name}
f = append(f, fi)
continue
}
ffi := fields(tfi.Type)
if !tfi.Anonymous {
for i := range ffi {
ffi[i].name = fmt.Sprintf(
"%s-%s",
strcase.ToKebab(tfi.Name),
ffi[i].name,
)
ffi[i].path = append(
[]string{tfi.Name},
ffi[i].path...,
)
}
}
f = append(f, ffi...)
}
if list {
for i := range f {
f[i].list = true
}
}
return f
}
func fieldFromValue(name string, v reflect.Value) Field {
var fi Field
fi.name = strcase.ToKebab(name)
fi.path = []string{name}
fi.value = v.Interface()
fi.isBool = v.Kind() == reflect.Bool
return fi
}
func scalarMapFields(v reflect.Value) []Field {
var f []Field
for _, key := range v.MapKeys() {
value := v.MapIndex(key)
name := key.Interface().(string)
if value.Kind() == reflect.Slice {
for i := 0; i < value.Len(); i++ {
fi := fieldFromValue(name, value.Index(i))
fi.free = true
f = append(f, fi)
}
} else {
fi := fieldFromValue(name, value)
fi.free = true
f = append(f, fi)
}
}
return f
}
func prependFieldName(name string, f []Field) {
for i := range f {
f[i].name = fmt.Sprintf("%s-%s", strcase.ToKebab(name), f[i].name)
f[i].path = append([]string{name}, f[i].path...)
}
}
func listFieldValues(fieldName string, l reflect.Value) []Field {
var f []Field
for i := 0; i < l.Len(); i++ {
item := l.Index(i)
item = unpackValue(item, pointer|iface|anytype)
switch {
case isScalar(item.Type()):
f = append(f, fieldFromValue(fieldName, item))
case item.Kind() == reflect.Slice:
f = append(f, listFieldValues(fieldName, item)...)
case isScalarMap(item.Type()):
mf := fieldValues(item)
prependFieldName(fieldName, mf)
f = append(f, mf...)
case item.Kind() == reflect.Struct:
sf := fieldValues(item)
prependFieldName(fieldName, sf)
f = append(f, sf...)
}
}
return f
}
func fieldValues(v reflect.Value) []Field {
var f []Field
v = unpackValue(v, pointer|anytype|iface)
if v.Kind() == reflect.Slice {
for i := 0; i < v.Len(); i++ {
f = append(f, fieldValues(v.Index(i))...)
}
return f
}
t := v.Type()
if isScalarMap(t) {
return scalarMapFields(v)
}
if t.Kind() != reflect.Struct {
return nil
}
for i := 0; i < t.NumField(); i++ {
tfi := t.Field(i)
if !exported(tfi.Name) && !tfi.Anonymous {
continue
}
vfi := v.Field(i)
vfi = unpackValue(vfi, pointer|iface|anytype)
switch {
case isScalar(vfi.Type()):
f = append(f, fieldFromValue(tfi.Name, vfi))
case vfi.Kind() == reflect.Slice:
f = append(f, listFieldValues(tfi.Name, vfi)...)
case isScalarMap(vfi.Type()):
mf := fieldValues(vfi)
prependFieldName(tfi.Name, mf)
f = append(f, mf...)
case vfi.Kind() == reflect.Struct:
sf := fieldValues(vfi)
if !tfi.Anonymous {
prependFieldName(tfi.Name, sf)
}
f = append(f, sf...)
}
}
return f
}
func fieldsReflect[T any]() []Field {
t := reflect.TypeFor[T]()
if hasCircularType(nil, t) {
return nil
}
return fields(t)
}
func fieldValuesReflect(structure any) []Field {
v := reflect.ValueOf(structure)
if hasCircularReference(nil, v) {
return nil
}
return fieldValues(v)
}

278
field_test.go Normal file
View File

@ -0,0 +1,278 @@
package bind_test
import (
"code.squareroundforest.org/arpio/bind"
"code.squareroundforest.org/arpio/notation"
"slices"
"sort"
"testing"
)
func TestField(t *testing.T) {
t.Run("fields", func(t *testing.T) {
type s0 struct {
FieldOne int
}
type s1 struct {
Foo int
}
type s2 struct {
s0
foo int
FooBar int
Baz s1
Qux *s1
Chan chan int
Que bool
Hola []int
}
f := bind.Fields[s2]()
if len(f) != 6 {
t.Fatal(notation.Sprintwt(f))
}
m := make(map[string]bind.Field)
for _, fi := range f {
m[fi.Name()] = fi
}
fieldOne := m["field-one"]
if !slices.Equal(fieldOne.Path(), []string{"FieldOne"}) {
t.Fatal(fieldOne.Name())
}
fooBar := m["foo-bar"]
if !slices.Equal(fooBar.Path(), []string{"FooBar"}) {
t.Fatal(fooBar.Name())
}
bazFoo := m["baz-foo"]
if !slices.Equal(bazFoo.Path(), []string{"Baz", "Foo"}) {
t.Fatal(bazFoo.Name())
}
quxFoo := m["qux-foo"]
if !slices.Equal(quxFoo.Path(), []string{"Qux", "Foo"}) {
t.Fatal(quxFoo.Name())
}
que := m["que"]
if !slices.Equal(que.Path(), []string{"Que"}) || !que.Bool() {
t.Fatal(que.Name())
}
hola := m["hola"]
if !slices.Equal(hola.Path(), []string{"Hola"}) || !hola.List() {
t.Fatal(hola.Name())
}
t.Run("cannot have fields", func(t *testing.T) {
type i []int
f := bind.Fields[i]()
if len(f) != 0 {
t.Fatal()
}
})
t.Run("has circular type", func(t *testing.T) {
type s struct{ Foo *s }
if len(bind.Fields[s]()) != 0 {
t.Fatal()
}
})
t.Run("list", func(t *testing.T) {
type s struct{ Foo int }
f := bind.Fields[[]s]()
if len(f) != 1 || !f[0].List() {
t.Fatal()
}
})
})
t.Run("field values", func(t *testing.T) {
t.Run("has circular reference", func(t *testing.T) {
type s struct{ Foo any }
var v s
v.Foo = v
if len(bind.FieldValues(v)) != 0 {
t.Fatal()
}
})
t.Run("slice", func(t *testing.T) {
type s struct{ Foo int }
f := bind.FieldValues([]s{{21}, {42}})
if len(f) != 2 || f[0].Value() != 21 || f[1].Value() != 42 {
t.Fatal()
}
})
t.Run("scalar map", func(t *testing.T) {
f := bind.FieldValues(map[string]int{"foo": 21, "bar": 42})
sort.Slice(f, func(i, j int) bool {
if f[i].Name() > f[j].Name() {
return true
}
if f[i].Value().(int) < f[j].Value().(int) {
return true
}
return false
})
if len(f) != 2 ||
f[0].Name() != "foo" || f[0].Value() != 21 || !f[0].Free() ||
f[1].Name() != "bar" || f[1].Value() != 42 || !f[1].Free() {
t.Fatal(notation.Println(f))
}
})
t.Run("scalar map with list values", func(t *testing.T) {
f := bind.FieldValues(map[string][]int{"foo": []int{21, 36}, "bar": []int{42, 72}})
sort.Slice(f, func(i, j int) bool {
if f[i].Name() > f[j].Name() {
return true
}
if f[i].Value().(int) < f[j].Value().(int) {
return true
}
return false
})
if len(f) != 4 ||
f[0].Name() != "foo" || f[0].Value() != 21 || !f[0].Free() ||
f[1].Name() != "foo" || f[1].Value() != 36 || !f[1].Free() ||
f[2].Name() != "bar" || f[2].Value() != 42 || !f[2].Free() ||
f[3].Name() != "bar" || f[3].Value() != 72 || !f[3].Free() {
t.Fatal(notation.Println(f))
}
})
t.Run("not a struct", func(t *testing.T) {
v := []int{21, 42, 84}
f := bind.FieldValues(v)
if len(f) != 0 {
t.Fatal()
}
})
t.Run("not exported field", func(t *testing.T) {
type s struct {
Foo int
bar string
}
v := s{Foo: 42, bar: "baz"}
f := bind.FieldValues(v)
if len(f) != 1 || f[0].Name() != "foo" {
t.Fatal()
}
})
t.Run("scalar fields", func(t *testing.T) {
type s struct {
Foo int
Bar bool
}
v := s{Foo: 42, Bar: true}
f := bind.FieldValues(v)
if len(f) != 2 ||
f[0].Name() != "foo" || !slices.Equal(f[0].Path(), []string{"Foo"}) || f[0].Value() != 42 || f[0].Bool() ||
f[1].Name() != "bar" || !slices.Equal(f[1].Path(), []string{"Bar"}) || f[1].Value() != true || !f[1.].Bool() {
t.Fatal()
}
})
t.Run("list field", func(t *testing.T) {
type s struct{ Foo []int }
v := s{Foo: []int{21, 42, 84}}
f := bind.FieldValues(v)
if len(f) != 3 ||
f[0].Name() != "foo" || f[0].Value() != 21 ||
f[1].Name() != "foo" || f[1].Value() != 42 ||
f[2].Name() != "foo" || f[2].Value() != 84 {
t.Fatal(notation.Sprintwt(f))
}
})
t.Run("list of lists", func(t *testing.T) {
type s struct{ Foo [][]int }
v := s{Foo: [][]int{{21}, {42}}}
f := bind.FieldValues(v)
if len(f) != 2 ||
f[0].Name() != "foo" || f[0].Value() != 21 ||
f[1].Name() != "foo" || f[1].Value() != 42 {
t.Fatal()
}
})
t.Run("list of scalar maps", func(t *testing.T) {
type s struct{ Foo any }
v := s{Foo: []any{map[string]int{"foo": 42}}}
f := bind.FieldValues(v)
if len(f) != 1 || f[0].Name() != "foo-foo" || f[0].Value() != 42 {
t.Fatal(notation.Sprintwt(f))
}
})
t.Run("list of structs", func(t *testing.T) {
type s struct{ Foo any }
v := s{Foo: []any{s{Foo: 21}, s{Foo: 42}}}
f := bind.FieldValues(v)
if len(f) != 2 ||
f[0].Name() != "foo-foo" || f[0].Value() != 21 ||
f[1].Name() != "foo-foo" || f[1].Value() != 42 {
t.Fatal()
}
})
t.Run("scalar map field", func(t *testing.T) {
type s struct{ Foo map[string]int }
v := s{Foo: map[string]int{"foo": 42}}
f := bind.FieldValues(v)
if len(f) != 1 || f[0].Name() != "foo-foo" || f[0].Value() != 42 {
t.Fatal()
}
})
t.Run("anonymous field", func(t *testing.T) {
type s0 struct{ Foo int }
type s1 struct {
s0
Bar int
}
var v s1
v.Foo = 21
v.Bar = 42
f := bind.FieldValues(v)
if len(f) != 2 ||
f[0].Name() != "foo" || f[0].Value() != 21 ||
f[1].Name() != "bar" || f[1].Value() != 42 {
t.Fatal()
}
})
t.Run("child struct", func(t *testing.T) {
type S0 struct{ Foo int }
type s1 struct {
S0 S0
Bar int
}
var v s1
v.S0.Foo = 21
v.Bar = 42
f := bind.FieldValues(v)
if len(f) != 2 ||
f[0].Name() != "s-0-foo" || f[0].Value() != 21 ||
f[1].Name() != "bar" || f[1].Value() != 42 {
t.Fatal(notation.Sprintwt(f))
}
})
})
}

5
go.mod
View File

@ -1,3 +1,8 @@
module code.squareroundforest.org/arpio/bind
go 1.25.0
require (
code.squareroundforest.org/arpio/notation v0.0.0-20250826181910-5140794b16b2 // indirect
github.com/iancoleman/strcase v0.3.0 // indirect
)

4
go.sum Normal file
View File

@ -0,0 +1,4 @@
code.squareroundforest.org/arpio/notation v0.0.0-20250826181910-5140794b16b2 h1:S4mjQHL70CuzFg1AGkr0o0d+4M+ZWM0sbnlYq6f0b3I=
code.squareroundforest.org/arpio/notation v0.0.0-20250826181910-5140794b16b2/go.mod h1:ait4Fvg9o0+bq5hlxi9dAcPL5a+/sr33qsZPNpToMLY=
github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI=
github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=

11
lib.go
View File

@ -1,10 +1,15 @@
// provides more flexible and permissive ways of setting values than reflect.Value.Set
// circular type structures not supported
// it handles scalar fields
// primary use cases by design are: command line options, environment variables, ini file fields, URL query parameters, HTTP form values
package bind
type Field struct {
name string
path []string
isBool bool
list bool
free bool
value any
}
@ -33,14 +38,16 @@ func (f Field) Path() []string {
func (f Field) Name() string { return f.name }
func (f Field) List() bool { return f.list }
func (f Field) Bool() bool { return f.isBool }
func (f Field) Free() bool { return f.free }
func (f Field) Value() any { return f.value }
func Fields[T any]() []Field {
return nil
return fieldsReflect[T]()
}
func FieldValues(structure any) []Field {
return nil
return fieldValuesReflect(structure)
}
func BindFields(structure any, values []Field) []Field {

3
notes.txt Normal file
View File

@ -0,0 +1,3 @@
maps with string keys and scalar, or list of scalar, values can be supported
fields being set by name, can be converted first to use path
add time and duration support. Can there be any other important quasi-scalars?

View File

@ -64,14 +64,38 @@ func bindScalarCreate(t reflect.Type, values []any) (reflect.Value, bool) {
}
func bindScalarReflect(receiver any, values []any) bool {
return bindScalar(reflect.ValueOf(receiver), values)
v := reflect.ValueOf(receiver)
if hasCircularReference(nil, v) {
return false
}
for _, vi := range values {
if hasCircularReference(nil, reflect.ValueOf(vi)) {
return false
}
}
return bindScalar(v, values)
}
func bindScalarCreateReflect[T any](values []any) (T, bool) {
v, ok := bindScalarCreate(reflect.TypeFor[T](), values)
t := reflect.TypeFor[T]()
if hasCircularType(nil, t) {
var tt T
return tt, false
}
for _, vi := range values {
if hasCircularReference(nil, reflect.ValueOf(vi)) {
var tt T
return tt, false
}
}
v, ok := bindScalarCreate(t, values)
if !ok {
var v T
return v, false
var tt T
return tt, false
}
return v.Interface().(T), true

View File

@ -146,6 +146,16 @@ func TestScalar(t *testing.T) {
t.Fatal()
}
})
t.Run("value has circular reference", func(t *testing.T) {
var r any
var v any
p := &v
*p = p
if bind.BindScalar(&r, p) {
t.Fatal()
}
})
})
t.Run("bind scalar with create", func(t *testing.T) {
@ -202,5 +212,21 @@ func TestScalar(t *testing.T) {
t.Fatal()
}
})
t.Run("receiver has circular type", func(t *testing.T) {
type s []s
if _, ok := bind.BindScalarCreate[s]("foo"); ok {
t.Fatal()
}
})
t.Run("value has circular reference", func(t *testing.T) {
var v any
p := &v
*p = p
if _, ok := bind.BindScalarCreate[any](p); ok {
t.Fatal()
}
})
})
}

View File

@ -69,6 +69,7 @@ func scanString(t reflect.Type, s string) (any, bool) {
}
func scan(t reflect.Type, v any) (any, bool) {
// time and duration support
if vv, ok := scanConvert(t, v); ok {
return vv, true
}

View File

@ -1,8 +1,8 @@
package bind_test
import (
"testing"
"code.squareroundforest.org/arpio/bind"
"testing"
)
func TestScan(t *testing.T) {

View File

@ -1 +0,0 @@
package bind

120
type.go
View File

@ -15,6 +15,86 @@ func (f unpackFlag) has(v unpackFlag) bool {
return f&v > 0
}
func setVisited[T comparable](visited map[T]bool, k T) map[T]bool {
s := make(map[T]bool)
for v := range visited {
s[v] = true
}
s[k] = true
return s
}
func hasCircularType(visited map[reflect.Type]bool, t reflect.Type) bool {
if visited[t] {
return true
}
switch t.Kind() {
case reflect.Pointer, reflect.Slice:
visited = setVisited(visited, t)
return hasCircularType(visited, t.Elem())
case reflect.Struct:
visited = setVisited(visited, t)
for i := 0; i < t.NumField(); i++ {
if hasCircularType(visited, t.Field(i).Type) {
return true
}
}
return false
default:
return false
}
}
func hasCircularReference(visited map[uintptr]bool, v reflect.Value) bool {
if hasCircularType(nil, v.Type()) {
return true
}
switch v.Kind() {
case reflect.Pointer:
p := v.Pointer()
if visited[p] {
return true
}
visited = setVisited(visited, v.Pointer())
return hasCircularReference(visited, v.Elem())
case reflect.Slice:
p := v.Pointer()
if visited[p] {
return true
}
visited = setVisited(visited, v.Pointer())
for i := 0; i < v.Len(); i++ {
if hasCircularReference(visited, v.Index(i)) {
return true
}
}
return false
case reflect.Interface:
if v.IsNil() {
return false
}
return hasCircularReference(visited, v.Elem())
case reflect.Struct:
for i := 0; i < v.NumField(); i++ {
if hasCircularReference(visited, v.Field(i)) {
return true
}
}
return false
}
return false
}
func isAny(t reflect.Type) bool {
return t.Kind() == reflect.Interface && t.NumMethod() == 0
}
@ -115,20 +195,52 @@ func bindable(t reflect.Type) bool {
return acceptsScalar(t) || acceptsFields(t)
}
func acceptsScalarChecked(t reflect.Type) bool {
if hasCircularType(nil, t) {
return false
}
return acceptsScalar(t)
}
func acceptsFieldsChecked(t reflect.Type) bool {
if hasCircularType(nil, t) {
return false
}
return acceptsFields(t)
}
func acceptsListChecked(t reflect.Type) bool {
if hasCircularType(nil, t) {
return false
}
return acceptsList(t)
}
func bindableChecked(t reflect.Type) bool {
if hasCircularType(nil, t) {
return false
}
return bindable(t)
}
func acceptsScalarReflect[T any]() bool {
return acceptsScalar(reflect.TypeFor[T]())
return acceptsScalarChecked(reflect.TypeFor[T]())
}
func acceptsFieldsReflect[T any]() bool {
return acceptsFields(reflect.TypeFor[T]())
return acceptsFieldsChecked(reflect.TypeFor[T]())
}
func acceptsListReflect[T any]() bool {
return acceptsList(reflect.TypeFor[T]())
return acceptsListChecked(reflect.TypeFor[T]())
}
func bindableReflect[T any]() bool {
return bindable(reflect.TypeFor[T]())
return bindableChecked(reflect.TypeFor[T]())
}
// expected to be called with types that can pass the bindable check

View File

@ -275,4 +275,94 @@ func TestTypeChecks(t *testing.T) {
}
})
})
t.Run("circular type", func(t *testing.T) {
t.Run("via pointer", func(t *testing.T) {
type p *p
if bind.Bindable[p]() {
t.Fatal()
}
})
t.Run("via slice", func(t *testing.T) {
type s []s
if bind.Bindable[s]() {
t.Fatal()
}
})
t.Run("pointer via struct field", func(t *testing.T) {
type s struct{ f *s }
if bind.Bindable[s]() {
t.Fatal()
}
})
t.Run("slice via struct field", func(t *testing.T) {
type s struct{ f []s }
if bind.Bindable[s]() {
t.Fatal()
}
})
})
t.Run("circular reference", func(t *testing.T) {
t.Run("via pointer", func(t *testing.T) {
p := new(any)
*p = p
if bind.BindScalar(p, "42") {
t.Fatal()
}
})
t.Run("via slice", func(t *testing.T) {
s := make([]any, 1)
s[0] = s
if bind.BindScalar(s, "42") {
t.Fatal()
}
})
t.Run("via interface and pointer", func(t *testing.T) {
var v any
v = &v
if bind.BindScalar(v, "42") {
t.Fatal()
}
})
t.Run("via struct field and pointer", func(t *testing.T) {
type s struct{ F *s }
var v s
v.F = &v
if len(bind.FieldValues(v)) > 0 {
t.Fatal()
}
})
t.Run("via struct field and slice", func(t *testing.T) {
type s struct{ F []s }
var v s
v.F = []s{v}
if len(bind.FieldValues(v)) > 0 {
t.Fatal()
}
})
t.Run("via struct field and interface", func(t *testing.T) {
var s struct{ F any }
s.F = s
if len(bind.FieldValues(s)) > 0 {
t.Fatal()
}
})
t.Run("value with circular type", func(t *testing.T) {
type p *p
var v p
if len(bind.FieldValues(v)) > 0 {
t.Fatal()
}
})
})
}