diff options
| -rw-r--r-- | CHANGELOG.md | 4 | ||||
| -rw-r--r-- | plan.go | 32 | ||||
| -rw-r--r-- | plan_test.go | 46 |
3 files changed, 62 insertions, 20 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index d2b3838..97c5ca7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ API changes: - Add plan option `ReadOnly`. +Changes: + +- During planning, fixed recursing into fields of non-embedded struct-typed fields. + # v0.13.2 (2026-07-31) No changes to the previous version. After v0.13.1 also failed, I noticed that `git clone` on the primary repo fails with `Cannot obtain needed object 96727093c88e5db251c55eda31700bf67c832ce1 while processing commit 238b5820a9968cb4c775fd9cf2e7e2cfeb24c78e.`, the former being the digest of the `v0.1.0` tag object. No idea why, but a `git repack -adf` on the primary repo (i.e. the bare repo on the server) fixed that. However, the Go module proxy seems to once again have cached the fetch error, so here we go again with yet another release. Fingers crossed that this one will be picked up. @@ -148,7 +148,7 @@ func buildPlan(t reflect.Type, dialect Dialect, opts planOpts) (plan, error) { } // discover addressable fields in this type, collect information from markers and tags - for _, field := range reflect.VisibleFields(t) { + for _, field := range assignableFields(t) { // recurse into struct fields (i.e. ignore the struct itself and consider its members instead) // unless the field itself has a `db:"..."` tag if field.Type.Kind() == reflect.Struct || (field.Type.Kind() == reflect.Pointer && field.Type.Elem().Kind() == reflect.Struct) { @@ -168,11 +168,6 @@ func buildPlan(t reflect.Type, dialect Dialect, opts planOpts) (plan, error) { indexesOfOpaqueStructs = append(indexesOfOpaqueStructs, field.Index) } - // ignore unexported fields (otherwise reflect.Value.Interface() on the field would panic) - if field.PkgPath != "" { - continue - } - // ignore fields that are within a struct type that is mapped as a whole if slices.ContainsFunc(indexesOfOpaqueStructs, func(index []int) bool { return isWithin(field.Index, index) @@ -288,6 +283,31 @@ func buildPlan(t reflect.Type, dialect Dialect, opts planOpts) (plan, error) { return p, nil } +// Like reflect.VisibleFields(), but considers all fields within the type that +// are assignable (i.e. `v.FieldByIndex(...).Set(...)` does not panic). +func assignableFields(t reflect.Type) (result []reflect.StructField) { + for field := range t.Fields() { + // assignment is allowed for exported or embedded fields only + if field.IsExported() || field.Anonymous { + result = append(result, field) + + // recurse into struct fields + ft := field.Type + if ft.Kind() == reflect.Pointer { + ft = ft.Elem() + } + if ft.Kind() == reflect.Struct { + for _, subfield := range assignableFields(ft) { + subfield.Index = append(slices.Clone(field.Index), subfield.Index...) + result = append(result, subfield) + } + } + } + } + + return result +} + func (p plan) getNonAutoColumnNames() []string { result := make([]string, 0, len(p.AllColumnNames)-len(p.AutoColumnNames)) for _, columnName := range p.AllColumnNames { diff --git a/plan_test.go b/plan_test.go index 3247928..45666d7 100644 --- a/plan_test.go +++ b/plan_test.go @@ -51,17 +51,29 @@ func TestPlanFieldTraversal(t *testing.T) { private1 bool `db:"private1"` //nolint:unused Ignored any `db:"-"` Timestamps - yetMoreTimestamps + *yetMoreTimestamps + MoreText struct { + Description string + } + YetMoreText struct { + Payload string + } `db:"-"` + OpaqueText struct { + ShortMessage string + LongMessage string + } `db:"OpaqueText"` } // check that the plan for Log: - // 1. has no IndexByColumnName entries for marker types - // 2. uses the field name as a column name for "Message" - // 3. ignores "private1" because it cannot be written through reflection - // 4. ignores "Ignored" because its column name is "-" - // 5. traverses into "Timestamps" and includes its fields as well - // 6. traverses into "yetMoreTimestamps" as well (despite the extra pointer and the type being private) - // 7. recognizes "id" as an autofilled column + // 1. uses the field name as a column name for "Message" + // 2. ignores "private1" because it cannot be written through reflection + // 3. ignores "Ignored" because its column name is "-" + // 4. traverses into "Timestamps" and includes its fields as well + // 5. traverses into "yetMoreTimestamps" as well (despite the extra pointer and the type being private) + // 6. traverses into "MoreText" and includes its fields as well + // 7. does not traverse into "YetMoreText" and does not include its fields because of `db:"-"` + // 8. does not traverse into "OpaqueText" because the struct is mapped as a whole + // 9. recognizes "id" as an autofilled column p, err := buildPlan(reflect.TypeFor[Log](), PostgresDialect(), planOpts{ StructTagKey: "db", TableName: "log_entries", @@ -73,17 +85,23 @@ func TestPlanFieldTraversal(t *testing.T) { assert.Equal(t, onlyAnalysisResult(p), plan{ TypeName: "Log", TableName: "log_entries", - AllColumnNames: []string{"id", "Message", "created_at", "updated_at", "deleted_at"}, + AllColumnNames: []string{"id", "Message", "created_at", "updated_at", "deleted_at", "Description", "OpaqueText"}, PrimaryKeyColumnNames: []string{"id"}, AutoColumnNames: []string{"id"}, IndexByColumnName: map[string][]int{ - "id": {0}, - "Message": {1}, - "created_at": {4, 0}, - "updated_at": {4, 1}, - "deleted_at": {5, 0}, + "id": {0}, + "Message": {1}, + "created_at": {4, 0}, + "updated_at": {4, 1}, + "deleted_at": {5, 0}, + "Description": {6, 0}, + "OpaqueText": {8}, }, InsertUsesQueryRow: true, + TransparentPointerStructFields: []fieldInfo{{ + Name: "yetMoreTimestamps", + Index: []int{5}, + }}, }) } |
