From b3fa0b96d09f16fb107c4603c98d9a8756ea7e00 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Tue, 30 Jun 2026 20:46:41 +0200 Subject: [PATCH 1/2] direct: Fix deploy bug when a postgres field is set to its zero value The dyn->typed ForceSendFields routing only handled one level of struct embedding. When a field declared in an SDK spec embedded two levels deep (e.g. PostgresProject -> PostgresProjectConfig -> ProjectSpec) was set to its zero value, its name was recorded in the wrong struct's ForceSendFields. The direct engine then failed to serialize the plan state with "field X cannot be found in struct Y". Route each field's ForceSendFields entry to the struct that actually declares it, at any embedding depth. This affected postgres_projects (enable_pg_native_login: false), postgres_branches, and postgres_endpoints (replace_existing: false). Terraform was unaffected (it serializes via dyn, not the SDK marshaler). Add a unit regression test for the deep-embedding case and a systemic guard that round-trips every registered resource type's zero-value fields, so any future resource using the embedded-spec wrapper pattern is covered automatically. Co-authored-by: Isaac --- NEXT_CHANGELOG.md | 1 + .../basic/databricks.yml.tmpl | 1 + .../postgres_projects/basic/out.requests.json | 1 + bundle/config/resources_types_test.go | 88 +++++++++++++++++++ libs/dyn/convert/struct_info.go | 72 ++++++++------- libs/dyn/convert/to_typed.go | 13 +-- libs/dyn/convert/to_typed_test.go | 34 +++++++ 7 files changed, 171 insertions(+), 39 deletions(-) diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 1f54cef5fac..e0b2133502c 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -15,6 +15,7 @@ * direct: Fix spurious update when `apply_policy_default_values: true` is set on job task, for-each-task, or job cluster new_cluster ([#5731](https://github.com/databricks/cli/pull/5731)). Also fix spurious updates for for-each-task clusters due to missing backend defaults for `data_security_mode`, `node_type_id`, `driver_node_type_id`, `driver_instance_pool_id`, `enable_elastic_disk`, and `enable_local_disk_encryption`. * direct: Cluster resize now falls back to regular update if resize fails due to `INVALID_STATE` ([#5716](https://github.com/databricks/cli/pull/5716)). * Fixed `bundle deployment migrate` failing on `model_serving_endpoints`/`database_instances` with permissions (regression since v1.5.0) ([#5775](https://github.com/databricks/cli/pull/5775)). + * direct: Fix deploy bug when a `postgres_projects`, `postgres_branches`, or `postgres_endpoints` field is set to its zero value (e.g. `enable_pg_native_login: false`, `replace_existing: false`). ### Dependency updates diff --git a/acceptance/bundle/resources/postgres_projects/basic/databricks.yml.tmpl b/acceptance/bundle/resources/postgres_projects/basic/databricks.yml.tmpl index 140b9116c0e..c399622a738 100644 --- a/acceptance/bundle/resources/postgres_projects/basic/databricks.yml.tmpl +++ b/acceptance/bundle/resources/postgres_projects/basic/databricks.yml.tmpl @@ -9,6 +9,7 @@ resources: my_project: project_id: test-pg-proj-$UNIQUE_NAME display_name: "Test Postgres Project" + enable_pg_native_login: false pg_version: 16 history_retention_duration: "604800s" default_endpoint_settings: diff --git a/acceptance/bundle/resources/postgres_projects/basic/out.requests.json b/acceptance/bundle/resources/postgres_projects/basic/out.requests.json index 938ae44f313..3ab697ae02a 100644 --- a/acceptance/bundle/resources/postgres_projects/basic/out.requests.json +++ b/acceptance/bundle/resources/postgres_projects/basic/out.requests.json @@ -12,6 +12,7 @@ "suspend_timeout_duration": "300s" }, "display_name": "Test Postgres Project", + "enable_pg_native_login": false, "history_retention_duration": "604800s", "pg_version": 16 } diff --git a/bundle/config/resources_types_test.go b/bundle/config/resources_types_test.go index 5d2a7298c6f..4dd0b18d26a 100644 --- a/bundle/config/resources_types_test.go +++ b/bundle/config/resources_types_test.go @@ -1,12 +1,18 @@ package config import ( + "encoding/json" "reflect" + "slices" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/databricks/cli/bundle/config/resources" + "github.com/databricks/cli/libs/dyn" + "github.com/databricks/cli/libs/dyn/convert" + "github.com/databricks/cli/libs/structs/structtag" ) func TestResourcesTypesMap(t *testing.T) { @@ -20,3 +26,85 @@ func TestResourcesTypesMap(t *testing.T) { assert.True(t, ok, "resources type for 'jobs.permissions' not found in ResourcesTypes map") assert.Equal(t, reflect.TypeFor[[]resources.JobPermission](), typ, "resources type for 'jobs.permissions' mismatch") } + +// TestResourceTypesZeroValueFieldsSerialize guards against the ForceSendFields +// routing bug fixed in libs/dyn/convert: a field declared in a struct embedded +// more than one level deep (e.g. PostgresProject -> PostgresProjectConfig -> +// ProjectSpec) had its zero value recorded in the wrong struct's ForceSendFields, +// which the SDK marshaler rejects with "field X cannot be found in struct Y". +// The direct engine hits this path when it serializes planned state to JSON. +// +// For every registered resource type it sets every omitempty scalar field (at any +// depth) to its zero value, converts via ToTyped, and marshals - the same round +// trip the direct engine performs. Any newly added resource whose wrapper embeds +// an SDK spec is covered automatically. +func TestResourceTypesZeroValueFieldsSerialize(t *testing.T) { + names := make([]string, 0, len(ResourcesTypes)) + for name := range ResourcesTypes { + names = append(names, name) + } + slices.Sort(names) + + for _, name := range names { + t.Run(name, func(t *testing.T) { + typ := ResourcesTypes[name] + zeros := zeroValueScalars(typ, 0, map[reflect.Type]bool{}) + if zeros.Kind() != dyn.KindMap { + return + } + + ptr := reflect.New(typ) + require.NoError(t, convert.ToTyped(ptr.Interface(), zeros)) + + _, err := json.Marshal(ptr.Interface()) + require.NoError(t, err) + }) + } +} + +// zeroValueScalars builds a [dyn.Value] map that sets every omitempty scalar field +// reachable through embedded anonymous structs to its zero value. Those are exactly +// the fields the convert layer records in ForceSendFields, so they exercise the +// routing logic. depth and seen bound recursion against deep or recursive types. +func zeroValueScalars(t reflect.Type, depth int, seen map[reflect.Type]bool) dyn.Value { + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + if t.Kind() != reflect.Struct || depth > 6 || seen[t] { + return dyn.NilValue + } + seen[t] = true + defer delete(seen, t) + + m := dyn.NewMapping() + for f := range t.Fields() { + if f.Anonymous { + if sub := zeroValueScalars(f.Type, depth+1, seen); sub.Kind() == dyn.KindMap { + for _, p := range sub.MustMap().Pairs() { + m.SetLoc(p.Key.MustString(), nil, p.Value) + } + } + continue + } + + tag := structtag.JSONTag(f.Tag.Get("json")) + name := tag.Name() + if name == "" || name == "-" || !f.IsExported() || !tag.OmitEmpty() { + continue + } + + switch f.Type.Kind() { + case reflect.Bool: + m.SetLoc(name, nil, dyn.V(false)) + case reflect.String: + m.SetLoc(name, nil, dyn.V("")) + case reflect.Int, reflect.Int32, reflect.Int64: + m.SetLoc(name, nil, dyn.V(int64(0))) + case reflect.Float32, reflect.Float64: + m.SetLoc(name, nil, dyn.V(float64(0))) + default: + // Only basic types are eligible for ForceSendFields; skip the rest. + } + } + return dyn.V(m) +} diff --git a/libs/dyn/convert/struct_info.go b/libs/dyn/convert/struct_info.go index 7e5b0bc741e..90b76e08b38 100644 --- a/libs/dyn/convert/struct_info.go +++ b/libs/dyn/convert/struct_info.go @@ -3,6 +3,8 @@ package convert import ( "reflect" "slices" + "strconv" + "strings" "sync" "github.com/databricks/cli/libs/dyn" @@ -28,9 +30,12 @@ type structInfo struct { // Maps JSON-name of the field to Golang struct name GolangNames map[string]string - // ForceSendFieldsStructKey maps the JSON-name of the field to which ForceSendFields slice it belongs to: - // -1 for direct fields, embedded struct index for embedded fields - ForceSendFieldsStructKey map[string]int + // ForceSendFieldsStructKey maps the JSON-name of the field to the struct that + // owns the ForceSendFields slice it belongs to. The value is the index path to + // that struct (see indexPathKey); "" is the top-level struct. The path can be + // more than one element deep because a field's declaring struct may be embedded + // several levels down (e.g. PostgresProject -> PostgresProjectConfig -> ProjectSpec). + ForceSendFieldsStructKey map[string]string } // structInfoCache caches type information. @@ -61,7 +66,7 @@ func buildStructInfo(typ reflect.Type) structInfo { Fields: make(map[string][]int), ForceEmpty: make(map[string]bool), GolangNames: make(map[string]string), - ForceSendFieldsStructKey: make(map[string]int), + ForceSendFieldsStructKey: make(map[string]string), } // Queue holds the indexes of the structs to visit. @@ -119,14 +124,9 @@ func buildStructInfo(typ reflect.Type) structInfo { } out.GolangNames[name] = sf.Name - // Determine which ForceSendFields this field belongs to - if len(prefix) == 0 { - // Direct field on the main struct - out.ForceSendFieldsStructKey[name] = -1 - } else { - // Field on embedded struct - out.ForceSendFieldsStructKey[name] = prefix[0] - } + // The field is declared directly in the struct reached by prefix, so + // its ForceSendFields lives there. prefix is empty for the top-level struct. + out.ForceSendFieldsStructKey[name] = indexPathKey(prefix) } } @@ -192,33 +192,45 @@ func (s *structInfo) FieldValues(v reflect.Value) []FieldValue { // Type of [dyn.Value]. var configValueType = reflect.TypeFor[dyn.Value]() -// getForceSendFieldsValues collects ForceSendFields reflect.Values -// Returns map[structKey]reflect.Value where structKey is -1 for direct fields, embedded index for embedded fields -func getForceSendFieldsValues(v reflect.Value) map[int]reflect.Value { - if !v.IsValid() || v.Type().Kind() != reflect.Struct { - return make(map[int]reflect.Value) - } +// getForceSendFieldsValues collects the ForceSendFields slice declared directly +// by the top-level struct and by every embedded struct reachable through anonymous +// fields, at any depth. The result is keyed by the index path to each owning struct +// (see indexPathKey), matching structInfo.ForceSendFieldsStructKey. Embedding can be +// arbitrarily deep (e.g. PostgresProject -> PostgresProjectConfig -> ProjectSpec), +// so each level is recorded under its own path rather than collapsed to one index. +func getForceSendFieldsValues(v reflect.Value) map[string]reflect.Value { + result := make(map[string]reflect.Value) + collectForceSendFieldsValues(v, nil, result) + return result +} - result := make(map[int]reflect.Value) +func collectForceSendFieldsValues(v reflect.Value, path []int, result map[string]reflect.Value) { + v = deref(v) + if !v.IsValid() || v.Kind() != reflect.Struct { + return + } for i := range v.Type().NumField() { field := v.Type().Field(i) fieldValue := v.Field(i) - if field.Name == "ForceSendFields" && !field.Anonymous { - // Direct ForceSendFields (structKey = -1) - result[-1] = fieldValue - } else if field.Anonymous { - // Embedded struct - check for ForceSendFields inside it - if embeddedStruct := deref(fieldValue); embeddedStruct.IsValid() { - if forceSendField := embeddedStruct.FieldByName("ForceSendFields"); forceSendField.IsValid() { - result[i] = forceSendField - } - } + switch { + case field.Name == "ForceSendFields" && !field.Anonymous: + result[indexPathKey(path)] = fieldValue + case field.Anonymous: + collectForceSendFieldsValues(fieldValue, append(path, i), result) } } +} - return result +// indexPathKey renders a reflect index path as a stable map key. The empty path +// (the top-level struct) renders as "". +func indexPathKey(path []int) string { + parts := make([]string, len(path)) + for i, x := range path { + parts[i] = strconv.Itoa(x) + } + return strings.Join(parts, ".") } // deref dereferences a pointer, returning invalid value if nil diff --git a/libs/dyn/convert/to_typed.go b/libs/dyn/convert/to_typed.go index 6fb63d1f0ba..491e325584f 100644 --- a/libs/dyn/convert/to_typed.go +++ b/libs/dyn/convert/to_typed.go @@ -76,7 +76,7 @@ func toTypedStruct(dst reflect.Value, src dyn.Value) error { info := getStructInfo(dst.Type()) forceSendFieldLocations := getForceSendFieldsValues(dst) - forceSendFieldsMap := make(map[int][]string) + forceSendFieldsMap := make(map[string][]string) for _, pair := range src.MustMap().Pairs() { pk := pair.Key @@ -111,14 +111,9 @@ func toTypedStruct(dst reflect.Value, src dyn.Value) error { } if pv.IsZero() { - // Use first index as key: -1 for direct fields, struct index for embedded fields - var structKey int - if len(index) == 1 { - structKey = -1 // Direct field - } else { - structKey = index[0] // Embedded struct index - } - + // The field's zero value must still serialize, so record its Go name + // in the ForceSendFields of the struct that declares it. + structKey := info.ForceSendFieldsStructKey[jsonKey] forceSendFieldsMap[structKey] = append(forceSendFieldsMap[structKey], info.GolangNames[jsonKey]) } } diff --git a/libs/dyn/convert/to_typed_test.go b/libs/dyn/convert/to_typed_test.go index 7b95d17056d..fd8213c16b1 100644 --- a/libs/dyn/convert/to_typed_test.go +++ b/libs/dyn/convert/to_typed_test.go @@ -754,3 +754,37 @@ func TestToTypedFieldByNameBugRegressionTest(t *testing.T) { assert.Equal(t, "test-job", out.Name) assert.Empty(t, out.Permissions) } + +func TestToTypedDeeplyEmbeddedStructForceSendFields(t *testing.T) { + // Mirrors resources.PostgresProject -> PostgresProjectConfig -> ProjectSpec: + // Spec is embedded two levels down and Wrapper shadows ForceSendFields to keep + // its own direct field out of Spec's ForceSendFields. A zero-value spec field + // must route to Spec.ForceSendFields, not Wrapper's, otherwise the SDK marshaler + // fails with "field ... cannot be found in struct". + type Spec struct { + SpecField bool `json:"spec_field,omitempty"` + ForceSendFields []string `json:"-"` + } + + type Wrapper struct { + Spec + WrapperField string `json:"wrapper_field,omitempty"` + ForceSendFields []string `json:"-"` + } + + type Outer struct { + Wrapper + } + + var out Outer + m := dyn.Mapping{} + m.SetLoc("spec_field", nil, dyn.V(false)) + m.SetLoc("wrapper_field", nil, dyn.V("")) + v := dyn.V(m) + + err := ToTyped(&out, v) + require.NoError(t, err) + + assert.Equal(t, []string{"SpecField"}, out.Spec.ForceSendFields) + assert.Equal(t, []string{"WrapperField"}, out.ForceSendFields) +} From 29cab63c33c4346317a6f258e52a30d669dbedbc Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Wed, 1 Jul 2026 15:49:41 +0200 Subject: [PATCH 2/2] dyn/convert: precompute ForceSendFields index path instead of walking at runtime Store the full reflect index path to each field's governing ForceSendFields slice, keyed by JSON name, instead of a single struct index. The previous map[string]int could only address a ForceSendFields one embed level down, so the deep-embedding fix walked the value at conversion time to relocate them. The location is static per type, so resolve it once in buildStructInfo and FieldByIndex into it from both consumers. Co-authored-by: Isaac --- .../configs/postgres_project.yml.tmpl | 1 + libs/dyn/convert/struct_info.go | 142 +++++++----------- libs/dyn/convert/to_typed.go | 55 ++++--- 3 files changed, 82 insertions(+), 116 deletions(-) diff --git a/acceptance/bundle/invariant/configs/postgres_project.yml.tmpl b/acceptance/bundle/invariant/configs/postgres_project.yml.tmpl index d78bdf6812c..aaa4b0c1c83 100644 --- a/acceptance/bundle/invariant/configs/postgres_project.yml.tmpl +++ b/acceptance/bundle/invariant/configs/postgres_project.yml.tmpl @@ -6,3 +6,4 @@ resources: foo: project_id: test-pg-project-$UNIQUE_NAME display_name: Test Postgres Project + enable_pg_native_login: false diff --git a/libs/dyn/convert/struct_info.go b/libs/dyn/convert/struct_info.go index 90b76e08b38..69e8f868ed5 100644 --- a/libs/dyn/convert/struct_info.go +++ b/libs/dyn/convert/struct_info.go @@ -3,8 +3,6 @@ package convert import ( "reflect" "slices" - "strconv" - "strings" "sync" "github.com/databricks/cli/libs/dyn" @@ -30,12 +28,15 @@ type structInfo struct { // Maps JSON-name of the field to Golang struct name GolangNames map[string]string - // ForceSendFieldsStructKey maps the JSON-name of the field to the struct that - // owns the ForceSendFields slice it belongs to. The value is the index path to - // that struct (see indexPathKey); "" is the top-level struct. The path can be - // more than one element deep because a field's declaring struct may be embedded - // several levels down (e.g. PostgresProject -> PostgresProjectConfig -> ProjectSpec). - ForceSendFieldsStructKey map[string]string + // ForceSendFieldsIndex maps the JSON-name of the field to the index path (for + // use with [reflect.Value.FieldByIndex]) of the ForceSendFields slice that + // governs it: the one declared by the struct that also declares the field. + // The path is static per type, so we resolve it once here rather than walking + // the value at conversion time. It can be more than one element deep because a + // field's declaring struct may be embedded several levels down (e.g. + // PostgresProject -> PostgresProjectConfig -> ProjectSpec). A field whose + // declaring struct has no ForceSendFields has no entry. + ForceSendFieldsIndex map[string][]int } // structInfoCache caches type information. @@ -63,10 +64,10 @@ func getStructInfo(typ reflect.Type) structInfo { // buildStructInfo populates a new [structInfo] for the given type. func buildStructInfo(typ reflect.Type) structInfo { out := structInfo{ - Fields: make(map[string][]int), - ForceEmpty: make(map[string]bool), - GolangNames: make(map[string]string), - ForceSendFieldsStructKey: make(map[string]string), + Fields: make(map[string][]int), + ForceEmpty: make(map[string]bool), + GolangNames: make(map[string]string), + ForceSendFieldsIndex: make(map[string][]int), } // Queue holds the indexes of the structs to visit. @@ -86,6 +87,15 @@ func buildStructInfo(typ reflect.Type) structInfo { styp = styp.Elem() } + // Index path to the ForceSendFields declared by this struct, if any. All + // fields declared directly by this struct are governed by it. The len==1 + // check excludes a ForceSendFields promoted from an embedded struct: that + // one governs the embedded struct's own fields, which we visit separately. + var forceSendFieldsIndex []int + if sf, ok := styp.FieldByName("ForceSendFields"); ok && len(sf.Index) == 1 { + forceSendFieldsIndex = append(slices.Clone(prefix), sf.Index...) + } + nf := styp.NumField() for j := range nf { sf := styp.Field(j) @@ -124,9 +134,11 @@ func buildStructInfo(typ reflect.Type) structInfo { } out.GolangNames[name] = sf.Name - // The field is declared directly in the struct reached by prefix, so - // its ForceSendFields lives there. prefix is empty for the top-level struct. - out.ForceSendFieldsStructKey[name] = indexPathKey(prefix) + // The field is declared directly in this struct, so it is governed by + // this struct's ForceSendFields (if it has one). + if forceSendFieldsIndex != nil { + out.ForceSendFieldsIndex[name] = forceSendFieldsIndex + } } } @@ -142,40 +154,15 @@ type FieldValue struct { func (s *structInfo) FieldValues(v reflect.Value) []FieldValue { out := make([]FieldValue, 0, len(s.Fields)) - // Collect ForceSendFields from all levels for field inclusion logic - forceSendFieldsMap := getForceSendFieldsValues(v) - for _, k := range s.FieldNames { - index := s.Fields[k] - fv := v - - // Locate value in struct (it could be an embedded type). - for i, x := range index { - if i > 0 { - if fv.Kind() == reflect.Pointer && fv.Type().Elem().Kind() == reflect.Struct { - if fv.IsNil() { - fv = reflect.Value{} - break - } - fv = fv.Elem() - } - } - fv = fv.Field(x) - } + fv := fieldByIndex(v, s.Fields[k]) if fv.IsValid() { isForced := true // TODO: we should use isEmptyForOmitEmpty instead of IsZero() if fv.IsZero() { - goName := s.GolangNames[k] - structKey := s.ForceSendFieldsStructKey[k] - if fieldValue, exists := forceSendFieldsMap[structKey]; exists { - forceSendFields := fieldValue.Interface().([]string) - isForced = slices.Contains(forceSendFields, goName) - } else { - isForced = false - } + isForced = s.isForceSend(v, k) } out = append(out, FieldValue{ @@ -189,57 +176,36 @@ func (s *structInfo) FieldValues(v reflect.Value) []FieldValue { return out } -// Type of [dyn.Value]. -var configValueType = reflect.TypeFor[dyn.Value]() - -// getForceSendFieldsValues collects the ForceSendFields slice declared directly -// by the top-level struct and by every embedded struct reachable through anonymous -// fields, at any depth. The result is keyed by the index path to each owning struct -// (see indexPathKey), matching structInfo.ForceSendFieldsStructKey. Embedding can be -// arbitrarily deep (e.g. PostgresProject -> PostgresProjectConfig -> ProjectSpec), -// so each level is recorded under its own path rather than collapsed to one index. -func getForceSendFieldsValues(v reflect.Value) map[string]reflect.Value { - result := make(map[string]reflect.Value) - collectForceSendFieldsValues(v, nil, result) - return result -} - -func collectForceSendFieldsValues(v reflect.Value, path []int, result map[string]reflect.Value) { - v = deref(v) - if !v.IsValid() || v.Kind() != reflect.Struct { - return - } - - for i := range v.Type().NumField() { - field := v.Type().Field(i) - fieldValue := v.Field(i) - - switch { - case field.Name == "ForceSendFields" && !field.Anonymous: - result[indexPathKey(path)] = fieldValue - case field.Anonymous: - collectForceSendFieldsValues(fieldValue, append(path, i), result) - } +// isForceSend reports whether the field named k is listed in the ForceSendFields +// that governs it (see structInfo.ForceSendFieldsIndex). +func (s *structInfo) isForceSend(v reflect.Value, k string) bool { + index, ok := s.ForceSendFieldsIndex[k] + if !ok { + return false } -} - -// indexPathKey renders a reflect index path as a stable map key. The empty path -// (the top-level struct) renders as "". -func indexPathKey(path []int) string { - parts := make([]string, len(path)) - for i, x := range path { - parts[i] = strconv.Itoa(x) + fsf := fieldByIndex(v, index) + if !fsf.IsValid() { + return false } - return strings.Join(parts, ".") + return slices.Contains(fsf.Interface().([]string), s.GolangNames[k]) } -// deref dereferences a pointer, returning invalid value if nil -func deref(v reflect.Value) reflect.Value { - if v.Kind() == reflect.Pointer { - if v.IsNil() { - return reflect.Value{} +// fieldByIndex resolves the value at the given index path, dereferencing embedded +// pointer structs on the way. It returns an invalid value if a nil pointer is met. +func fieldByIndex(v reflect.Value, index []int) reflect.Value { + for i, x := range index { + if i > 0 { + if v.Kind() == reflect.Pointer && v.Type().Elem().Kind() == reflect.Struct { + if v.IsNil() { + return reflect.Value{} + } + v = v.Elem() + } } - return v.Elem() + v = v.Field(x) } return v } + +// Type of [dyn.Value]. +var configValueType = reflect.TypeFor[dyn.Value]() diff --git a/libs/dyn/convert/to_typed.go b/libs/dyn/convert/to_typed.go index 491e325584f..73ac79bca47 100644 --- a/libs/dyn/convert/to_typed.go +++ b/libs/dyn/convert/to_typed.go @@ -75,9 +75,6 @@ func toTypedStruct(dst reflect.Value, src dyn.Value) error { info := getStructInfo(dst.Type()) - forceSendFieldLocations := getForceSendFieldsValues(dst) - forceSendFieldsMap := make(map[string][]string) - for _, pair := range src.MustMap().Pairs() { pk := pair.Key pv := pair.Value @@ -90,20 +87,7 @@ func toTypedStruct(dst reflect.Value, src dyn.Value) error { continue } - // Create intermediate structs embedded as pointer types. - // Code inspired by [reflect.FieldByIndex] implementation. - f := dst - for i, x := range index { - if i > 0 { - if f.Kind() == reflect.Pointer { - if f.IsNil() { - f.Set(reflect.New(f.Type().Elem())) - } - f = f.Elem() - } - } - f = f.Field(x) - } + f := fieldByIndexAlloc(dst, index) err := ToTyped(f.Addr().Interface(), pv) if err != nil { @@ -111,17 +95,14 @@ func toTypedStruct(dst reflect.Value, src dyn.Value) error { } if pv.IsZero() { - // The field's zero value must still serialize, so record its Go name - // in the ForceSendFields of the struct that declares it. - structKey := info.ForceSendFieldsStructKey[jsonKey] - forceSendFieldsMap[structKey] = append(forceSendFieldsMap[structKey], info.GolangNames[jsonKey]) - } - } - - // Set ForceSendFields using precalculated locations - for structKey, fields := range forceSendFieldsMap { - if forceSendFieldLocation, exists := forceSendFieldLocations[structKey]; exists { - forceSendFieldLocation.Set(reflect.ValueOf(fields)) + // The field's zero value must still serialize, so append its Go name + // to the ForceSendFields of the struct that declares it. That struct + // shares a prefix with the field's index path, so it is already + // allocated by the walk above. + if fsfIndex, ok := info.ForceSendFieldsIndex[jsonKey]; ok { + fsf := fieldByIndexAlloc(dst, fsfIndex) + fsf.Set(reflect.Append(fsf, reflect.ValueOf(info.GolangNames[jsonKey]))) + } } } @@ -151,6 +132,24 @@ func toTypedStruct(dst reflect.Value, src dyn.Value) error { } } +// fieldByIndexAlloc resolves the value at the given index path within an addressable +// struct, allocating intermediate structs embedded as pointer types along the way. +// Code inspired by [reflect.FieldByIndex] implementation. +func fieldByIndexAlloc(v reflect.Value, index []int) reflect.Value { + for i, x := range index { + if i > 0 { + if v.Kind() == reflect.Pointer { + if v.IsNil() { + v.Set(reflect.New(v.Type().Elem())) + } + v = v.Elem() + } + } + v = v.Field(x) + } + return v +} + func toTypedMap(dst reflect.Value, src dyn.Value) error { switch src.Kind() { case dyn.KindMap: