Build reusable schema descriptors

This commit is contained in:
2026-09-06 19:36:14 +00:00
parent 7c78da9cd2
commit 2b65876a7d
7 changed files with 789 additions and 17 deletions

304
schema_test.go Normal file
View File

@@ -0,0 +1,304 @@
package configkit
import (
"reflect"
"testing"
"time"
)
type namedCount int32
type textValue struct {
value string
}
func (v textValue) MarshalText() ([]byte, error) {
return []byte(v.value), nil
}
func (v *textValue) UnmarshalText(data []byte) error {
v.value = string(data)
return nil
}
type marshalOnly string
func (v marshalOnly) MarshalText() ([]byte, error) {
return []byte(v), nil
}
type unmarshalOnly string
func (v *unmarshalOnly) UnmarshalText(data []byte) error {
*v = unmarshalOnly(data)
return nil
}
type recursiveConfig struct {
Name string `yaml:"name"`
Next *recursiveConfig `yaml:"next"`
Children map[string]recursiveConfig `yaml:"children"`
}
func TestBuildSchemaDescribesSupportedTypes(t *testing.T) {
t.Parallel()
type Embedded struct {
InlineName string `yaml:"inline_name"`
}
type nested struct {
Enabled bool `yaml:"enabled"`
}
type config struct {
HTTPServer string `yaml:"server,omitempty" configkit:"secret"`
Count namedCount `yaml:"count"`
Delay time.Duration `yaml:"delay"`
Custom textValue `yaml:"custom"`
Optional *textValue `yaml:"optional"`
Nested nested `yaml:"nested"`
Objects map[string]nested `yaml:"objects"`
Items []nested `yaml:"items"`
Fixed [2]string `yaml:"fixed"`
Dynamic any `yaml:"dynamic"`
*Embedded `yaml:",inline"`
Extras map[string]int `yaml:",inline"`
UntaggedField bool
Ignored chan int `yaml:"-"`
private chan int
}
schema, issue := buildSchema(reflect.TypeFor[config]())
if issue != nil {
t.Fatalf("buildSchema() issue = %#v", issue)
}
root := schema.root
checks := map[string]descriptorKind{
"server": descriptorString,
"count": descriptorSigned,
"delay": descriptorDuration,
"custom": descriptorText,
"optional": descriptorPointer,
"nested": descriptorStruct,
"objects": descriptorMap,
"items": descriptorSlice,
"fixed": descriptorArray,
"dynamic": descriptorDynamic,
"inline_name": descriptorString,
"untaggedfield": descriptorBoolean,
}
for name, want := range checks {
position, ok := root.fieldByName[name]
if !ok {
t.Errorf("field %q missing", name)
continue
}
got := root.fields[position].descriptor.kind
if got != want {
t.Errorf("field %q kind = %v, want %v", name, got, want)
}
}
server := root.fields[root.fieldByName["server"]]
if !server.secret {
t.Fatal("secret tag was not retained")
}
if root.inlineMap == nil || root.inlineMap.descriptor.kind != descriptorMap {
t.Fatalf("inline map = %#v", root.inlineMap)
}
if mapped, mapIssue := buildSchema(reflect.TypeFor[map[string]nested]()); mapIssue != nil || mapped.root.kind != descriptorMap {
t.Fatalf("string-keyed root map = %#v, issue=%#v", mapped, mapIssue)
}
if _, exists := root.fieldByName["ignored"]; exists {
t.Fatal("ignored field was bound")
}
}
func TestBuildSchemaSupportsRecursiveDescriptorsWithoutSharingLoads(t *testing.T) {
t.Parallel()
first, issue := buildSchema(reflect.TypeFor[recursiveConfig]())
if issue != nil {
t.Fatal(issue.message)
}
next := first.root.fields[first.root.fieldByName["next"]].descriptor
if next.kind != descriptorPointer || next.element != first.root {
t.Fatal("recursive pointer did not refer to the existing descriptor")
}
children := first.root.fields[first.root.fieldByName["children"]].descriptor
if children.kind != descriptorMap || children.element != first.root {
t.Fatal("recursive map did not refer to the existing descriptor")
}
second, issue := buildSchema(reflect.TypeFor[recursiveConfig]())
if issue != nil {
t.Fatal(issue.message)
}
if first.root == second.root {
t.Fatal("descriptor graph was cached across loads")
}
}
func TestBuildSchemaRejectsInvalidRootsTypesTagsAndBindings(t *testing.T) {
t.Parallel()
type InlineA struct {
Value string `yaml:"same"`
}
type InlineB struct {
Value string `yaml:"same"`
}
type ambiguous struct {
InlineA `yaml:",inline"`
InlineB `yaml:",inline"`
}
type directConflict struct {
Same string `yaml:"same"`
InlineA `yaml:",inline"`
}
type twoMaps struct {
First map[string]string `yaml:",inline"`
Second map[string]string `yaml:",inline"`
}
type invalidInline struct {
Value string `yaml:",inline"`
}
type invalidMap struct {
Values map[int]string `yaml:"values"`
}
type unsupported struct {
Value complex64 `yaml:"value"`
}
type badOption struct {
Value string `yaml:"value,flow"`
}
type badConfigTag struct {
Value string `configkit:"private"`
}
type missingMarshal struct {
Value unmarshalOnly `yaml:"value"`
}
type missingUnmarshal struct {
Value marshalOnly `yaml:"value"`
}
type RecursiveInline struct {
*RecursiveInline `yaml:",inline"`
}
tests := []struct {
name string
typeOf reflect.Type
code Code
}{
{name: "nil root", typeOf: nil, code: CodeSchemaRoot},
{name: "pointer root", typeOf: reflect.TypeFor[*recursiveConfig](), code: CodeSchemaRoot},
{name: "slice root", typeOf: reflect.TypeFor[[]string](), code: CodeSchemaRoot},
{name: "non-string root map", typeOf: reflect.TypeFor[map[int]string](), code: CodeSchemaRoot},
{name: "text scalar root", typeOf: reflect.TypeFor[textValue](), code: CodeSchemaRoot},
{name: "ambiguous fields", typeOf: reflect.TypeFor[ambiguous](), code: CodeSchemaAmbiguousField},
{name: "direct inline conflict", typeOf: reflect.TypeFor[directConflict](), code: CodeSchemaAmbiguousField},
{name: "multiple inline maps", typeOf: reflect.TypeFor[twoMaps](), code: CodeSchemaInline},
{name: "invalid inline", typeOf: reflect.TypeFor[invalidInline](), code: CodeSchemaInline},
{name: "invalid nested map", typeOf: reflect.TypeFor[invalidMap](), code: CodeSchemaUnsupportedType},
{name: "unsupported kind", typeOf: reflect.TypeFor[unsupported](), code: CodeSchemaUnsupportedType},
{name: "unsupported YAML option", typeOf: reflect.TypeFor[badOption](), code: CodeSchemaTag},
{name: "unsupported ConfigKit tag", typeOf: reflect.TypeFor[badConfigTag](), code: CodeSchemaTag},
{name: "missing text marshal", typeOf: reflect.TypeFor[missingMarshal](), code: CodeSchemaTextCodec},
{name: "missing text unmarshal", typeOf: reflect.TypeFor[missingUnmarshal](), code: CodeSchemaTextCodec},
{name: "recursive inline", typeOf: reflect.TypeFor[RecursiveInline](), code: CodeSchemaInline},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if _, issue := buildSchema(test.typeOf); issue == nil || issue.code != test.code {
t.Fatalf("issue = %#v, want %s", issue, test.code)
}
})
}
}
func TestBuildSchemaDoesNotExecuteApplicationMethods(t *testing.T) {
t.Parallel()
type config struct {
Value panicText `yaml:"value"`
Structural yamlHookStruct `yaml:"structural"`
}
if _, issue := buildSchema(reflect.TypeFor[config]()); issue != nil {
t.Fatal(issue.message)
}
}
type panicText struct{}
type yamlHookStruct struct {
Value string `yaml:"value"`
}
func (*yamlHookStruct) UnmarshalYAML(any) error {
panic("descriptor construction called parser hook")
}
func (panicText) MarshalText() ([]byte, error) {
panic("descriptor construction called MarshalText")
}
func (*panicText) UnmarshalText([]byte) error {
panic("descriptor construction called UnmarshalText")
}
func (panicText) UnmarshalYAML(any) error {
panic("descriptor construction called parser hook")
}
func TestResolveSchemaPathUsesContainerSemantics(t *testing.T) {
t.Parallel()
type item struct {
Name string `yaml:"name"`
}
type config struct {
Items []item `yaml:"items"`
Values map[string]item `yaml:"values"`
Fixed [2]string `yaml:"fixed"`
Dynamic any `yaml:"dynamic"`
Extras map[string]string `yaml:",inline"`
}
schema, issue := buildSchema(reflect.TypeFor[config]())
if issue != nil {
t.Fatal(issue.message)
}
valid := []Path{
Keys("items").Index(0).Key("name"),
mustPath(t, "/items/0/name"),
Keys("values", "0", "name"),
mustPath(t, "/values/0/name"),
Keys("fixed").Index(1),
Keys("dynamic", "anything").Index(9),
Keys("application_key"),
}
for _, path := range valid {
if _, ok := resolveSchemaPath(schema, path); !ok {
t.Errorf("valid path %q rejected", path.String())
}
}
invalid := []Path{
Keys("missing", "field"),
Keys("items", "0", "name"),
mustPath(t, "/items/00/name"),
Keys("fixed").Index(2),
Keys("values").Index(0),
Path{}.Index(-1),
}
for _, path := range invalid {
if _, ok := resolveSchemaPath(schema, path); ok {
t.Errorf("invalid path %q accepted", path.String())
}
}
}
func mustPath(t *testing.T, pointer string) Path {
t.Helper()
path, err := ParsePath(pointer)
if err != nil {
t.Fatal(err)
}
return path
}