Skip to content

Commit 5fbad90

Browse files
authored
Add libs/structdiff (#2928)
## Changes New library to compare two Go structs and give field-by-field difference. Respects ForceSendFields. ## Why Using it trigger deployments in #2926 Will also be using it for "bundle diff" command, however, it would need to be extended to calculate proper minimal diff on slices. ## Tests New unit tests. ## Benchmark ``` + exec go test -bench=. -benchmem -run '^x' goos: darwin goarch: arm64 pkg: github.com/databricks/cli/libs/structdiff cpu: Apple M3 Max BenchmarkEqual-16 32433 35876 ns/op 13368 B/op 540 allocs/op BenchmarkChanges-16 31153 38006 ns/op 16008 B/op 598 allocs/op BenchmarkZero-16 27346 44260 ns/op 26011 B/op 843 allocs/op BenchmarkNils-16 37209 32295 ns/op 20541 B/op 572 allocs/op ```
1 parent 2968908 commit 5fbad90

File tree

6 files changed

+1065
-0
lines changed

6 files changed

+1065
-0
lines changed

libs/structdiff/bench.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
#!/bin/bash
2+
set -ex
3+
exec go test -bench=. -benchmem -run ^x

libs/structdiff/bench_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package structdiff
2+
3+
import (
4+
"encoding/json"
5+
"strings"
6+
"testing"
7+
8+
"github.com/databricks/databricks-sdk-go/service/jobs"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func bench(b *testing.B, job1, job2 string) {
13+
var x, y jobs.JobSettings
14+
15+
require.NoError(b, json.Unmarshal([]byte(job1), &x))
16+
require.NoError(b, json.Unmarshal([]byte(job2), &y))
17+
18+
total := 0
19+
20+
b.ResetTimer()
21+
for range b.N {
22+
changes, err := GetStructDiff(&x, &y)
23+
if err != nil {
24+
b.Fatalf("error: %s", err)
25+
}
26+
total += len(changes)
27+
}
28+
b.StopTimer()
29+
30+
b.Logf("Total: %d / %d", total, b.N)
31+
}
32+
33+
func BenchmarkEqual(b *testing.B) {
34+
bench(b, jobExampleResponse, jobExampleResponse)
35+
}
36+
37+
func BenchmarkChanges(b *testing.B) {
38+
job2 := strings.ReplaceAll(jobExampleResponse, "1", "2")
39+
bench(b, jobExampleResponse, job2)
40+
}
41+
42+
func BenchmarkZero(b *testing.B) {
43+
bench(b, jobExampleResponse, jobExampleResponseZeroes)
44+
}
45+
46+
func BenchmarkNils(b *testing.B) {
47+
bench(b, jobExampleResponse, jobExampleResponseNils)
48+
}

libs/structdiff/diff.go

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
package structdiff
2+
3+
import (
4+
"fmt"
5+
"reflect"
6+
"slices"
7+
"sort"
8+
"strconv"
9+
"strings"
10+
)
11+
12+
type Change struct {
13+
Field string
14+
Old any
15+
New any
16+
}
17+
18+
type pathNode struct {
19+
Prev *pathNode
20+
Key string
21+
// If Index >= 0, the node specifies a slice/array index in Index.
22+
// If Index == -1, the node specifies a struct attribute in Key
23+
// If Index == -2, the node specifies a map key in Key
24+
Index int
25+
}
26+
27+
func (p *pathNode) String() string {
28+
if p == nil {
29+
return ""
30+
}
31+
if p.Index >= 0 {
32+
return p.Prev.String() + "[" + strconv.Itoa(p.Index) + "]"
33+
}
34+
if p.Index == -1 {
35+
return p.Prev.String() + "." + p.Key
36+
}
37+
return fmt.Sprintf("%s[%q]", p.Prev.String(), p.Key)
38+
}
39+
40+
// GetStructDiff compares two Go structs and returns a list of Changes or an error.
41+
// Respects ForceSendFields if present.
42+
// Types of a and b must match exactly, otherwise returns an error.
43+
func GetStructDiff(a, b any) ([]Change, error) {
44+
v1 := reflect.ValueOf(a)
45+
v2 := reflect.ValueOf(b)
46+
47+
if !v1.IsValid() && !v2.IsValid() {
48+
return nil, nil
49+
}
50+
51+
var changes []Change
52+
53+
if !v1.IsValid() || !v2.IsValid() {
54+
changes = append(changes, Change{Field: "", Old: v1.Interface(), New: v2.Interface()})
55+
return changes, nil
56+
}
57+
58+
if v1.Type() != v2.Type() {
59+
return nil, fmt.Errorf("type mismatch: %v vs %v", v1.Type(), v2.Type())
60+
}
61+
62+
diffValues(nil, v1, v2, &changes)
63+
return changes, nil
64+
}
65+
66+
// diffValues appends changes between v1 and v2 to the slice. path is the current
67+
// JSON-style path (dot + brackets). At the root path is "".
68+
func diffValues(path *pathNode, v1, v2 reflect.Value, changes *[]Change) {
69+
if !v1.IsValid() {
70+
if !v2.IsValid() {
71+
return
72+
}
73+
74+
*changes = append(*changes, Change{Field: path.String(), Old: nil, New: v2.Interface()})
75+
return
76+
} else if !v2.IsValid() {
77+
// v1 is valid
78+
*changes = append(*changes, Change{Field: path.String(), Old: v1.Interface(), New: nil})
79+
return
80+
}
81+
82+
v1Type := v1.Type()
83+
84+
// This should not happen; if it does, record this a full change
85+
if v1Type != v2.Type() {
86+
*changes = append(*changes, Change{Field: path.String(), Old: v1.Interface(), New: v2.Interface()})
87+
return
88+
}
89+
90+
kind := v1.Kind()
91+
92+
switch kind {
93+
case reflect.Pointer, reflect.Map, reflect.Slice, reflect.Interface, reflect.Chan, reflect.Func:
94+
v1Nil := v1.IsNil()
95+
v2Nil := v2.IsNil()
96+
if v1Nil && v2Nil {
97+
return
98+
}
99+
if v1Nil || v2Nil {
100+
*changes = append(*changes, Change{Field: path.String(), Old: v1.Interface(), New: v2.Interface()})
101+
return
102+
}
103+
}
104+
105+
switch kind {
106+
case reflect.Pointer:
107+
diffValues(path, v1.Elem(), v2.Elem(), changes)
108+
case reflect.Struct:
109+
diffStruct(path, v1, v2, changes)
110+
case reflect.Slice, reflect.Array:
111+
if v1.Len() != v2.Len() {
112+
*changes = append(*changes, Change{Field: path.String(), Old: v1.Interface(), New: v2.Interface()})
113+
} else {
114+
for i := range v1.Len() {
115+
node := pathNode{Prev: path, Index: i}
116+
diffValues(&node, v1.Index(i), v2.Index(i), changes)
117+
}
118+
}
119+
case reflect.Map:
120+
if v1Type.Key().Kind() == reflect.String {
121+
diffMapStringKey(path, v1, v2, changes)
122+
} else {
123+
deepEqualValues(path, v1, v2, changes)
124+
}
125+
default:
126+
deepEqualValues(path, v1, v2, changes)
127+
}
128+
}
129+
130+
func deepEqualValues(path *pathNode, v1, v2 reflect.Value, changes *[]Change) {
131+
if !reflect.DeepEqual(v1.Interface(), v2.Interface()) {
132+
*changes = append(*changes, Change{Field: path.String(), Old: v1.Interface(), New: v2.Interface()})
133+
}
134+
}
135+
136+
func diffStruct(path *pathNode, s1, s2 reflect.Value, changes *[]Change) {
137+
t := s1.Type()
138+
forced1 := getForceSendFields(s1)
139+
forced2 := getForceSendFields(s2)
140+
141+
for i := range t.NumField() {
142+
sf := t.Field(i)
143+
if !sf.IsExported() || sf.Name == "ForceSendFields" {
144+
continue
145+
}
146+
147+
node := pathNode{Prev: path, Key: sf.Name, Index: -1}
148+
v1Field := s1.Field(i)
149+
v2Field := s2.Field(i)
150+
151+
hasOmitEmpty := strings.Contains(sf.Tag.Get("json"), "omitempty")
152+
153+
if hasOmitEmpty {
154+
if v1Field.IsZero() {
155+
if !slices.Contains(forced1, sf.Name) {
156+
v1Field = reflect.ValueOf(nil)
157+
}
158+
}
159+
if v2Field.IsZero() {
160+
if !slices.Contains(forced2, sf.Name) {
161+
v2Field = reflect.ValueOf(nil)
162+
}
163+
}
164+
}
165+
166+
diffValues(&node, v1Field, v2Field, changes)
167+
}
168+
}
169+
170+
func diffMapStringKey(path *pathNode, m1, m2 reflect.Value, changes *[]Change) {
171+
keySet := map[string]reflect.Value{}
172+
for _, k := range m1.MapKeys() {
173+
// Key is always string at this point
174+
ks := k.Interface().(string)
175+
keySet[ks] = k
176+
}
177+
for _, k := range m2.MapKeys() {
178+
ks := k.Interface().(string)
179+
keySet[ks] = k
180+
}
181+
182+
var keys []string
183+
for s := range keySet {
184+
keys = append(keys, s)
185+
}
186+
sort.Strings(keys)
187+
188+
for _, ks := range keys {
189+
k := keySet[ks]
190+
v1 := m1.MapIndex(k)
191+
v2 := m2.MapIndex(k)
192+
node := pathNode{
193+
Prev: path,
194+
Key: ks,
195+
Index: -2,
196+
}
197+
diffValues(&node, v1, v2, changes)
198+
}
199+
}
200+
201+
func getForceSendFields(v reflect.Value) []string {
202+
if !v.IsValid() || v.Kind() != reflect.Struct {
203+
return nil
204+
}
205+
fsField := v.FieldByName("ForceSendFields")
206+
if !fsField.IsValid() || fsField.Kind() != reflect.Slice {
207+
return nil
208+
}
209+
result, ok := fsField.Interface().([]string)
210+
if ok {
211+
return result
212+
}
213+
return nil
214+
}

0 commit comments

Comments
 (0)