feat: merge object shapes with optional fields in raw paths

This commit is contained in:
AJ ONeal 2026-06-11 19:10:26 -06:00
parent fabd193884
commit fbd00ae4d2
No known key found for this signature in database
5 changed files with 137 additions and 51 deletions

View File

@ -271,8 +271,8 @@ func stripTrailingDigits(name string) string {
return name[:i+1] return name[:i+1]
} }
// detectNullablePaths returns the set of paths where {null} appears // detectNullablePaths returns the set of paths where {null} or {undefined}
// alongside at least one concrete type. // appears alongside at least one concrete type.
func detectNullablePaths(lines []rawLine) map[string]bool { func detectNullablePaths(lines []rawLine) map[string]bool {
pathTypes := make(map[string][]string) pathTypes := make(map[string][]string)
for _, line := range lines { for _, line := range lines {
@ -281,16 +281,16 @@ func detectNullablePaths(lines []rawLine) map[string]bool {
nullables := make(map[string]bool) nullables := make(map[string]bool)
for path, typeVals := range pathTypes { for path, typeVals := range pathTypes {
hasNull := false hasAbsent := false
hasOther := false hasOther := false
for _, tv := range typeVals { for _, tv := range typeVals {
if tv == "null" { if tv == "null" || tv == "undefined" {
hasNull = true hasAbsent = true
} else if tv != "undefined" && tv != "empty" { } else if tv != "empty" {
hasOther = true hasOther = true
} }
} }
if hasNull && hasOther { if hasAbsent && hasOther {
nullables[path] = true nullables[path] = true
} }
} }
@ -306,8 +306,9 @@ func emitCoalesced(lines []rawLine, nameMap map[string]string, nullables map[str
for i < len(lines) { for i < len(lines) {
line := lines[i] line := lines[i]
// Skip standalone {null} lines at nullable paths (merged into ?). // Skip standalone {null} and {undefined} lines at nullable paths
if line.typeVal == "null" && nullables[line.path] { // (merged into ? on the concrete type line).
if (line.typeVal == "null" || line.typeVal == "undefined") && nullables[line.path] {
i++ i++
continue continue
} }

View File

@ -105,7 +105,7 @@ func TestCoalesceDedupManyNames(t *testing.T) {
t.Logf("output (%d lines):\n%s", len(got), strings.Join(got, "\n")) t.Logf("output (%d lines):\n%s", len(got), strings.Join(got, "\n"))
} }
func TestCoalesceUndefinedPreserved(t *testing.T) { func TestCoalesceUndefinedCollapses(t *testing.T) {
input := []string{ input := []string{
"[]{RootItem0}", "[]{RootItem0}",
"[].name{string}", "[].name{string}",
@ -115,16 +115,15 @@ func TestCoalesceUndefinedPreserved(t *testing.T) {
got := Coalesce(input) got := Coalesce(input)
// {undefined} should be preserved (not collapsed into ?). // {undefined} collapses into ? on the concrete type (same as null).
assertLineContains(t, got, "[].email{undefined}") assertLineContains(t, got, "[].email{string?}")
// {string} should NOT be nullable (undefined ≠ null). assertLineNotContains(t, got, "[].email{undefined}")
assertLineContains(t, got, "[].email{string}") assertLineNotContains(t, got, "[].email{string}")
assertLineNotContains(t, got, "[].email{string?}")
t.Logf("output (%d lines):\n%s", len(got), strings.Join(got, "\n")) t.Logf("output (%d lines):\n%s", len(got), strings.Join(got, "\n"))
} }
func TestCoalesceNullAndUndefined(t *testing.T) { func TestCoalesceNullAndUndefinedBothCollapse(t *testing.T) {
input := []string{ input := []string{
"[]{RootItem0}", "[]{RootItem0}",
"[].email{undefined}", "[].email{undefined}",
@ -134,10 +133,9 @@ func TestCoalesceNullAndUndefined(t *testing.T) {
got := Coalesce(input) got := Coalesce(input)
// {undefined} preserved // Both {undefined} and {null} collapse into {string?}.
assertLineContains(t, got, "[].email{undefined}")
// {null} collapsed into {string?}
assertLineContains(t, got, "[].email{string?}") assertLineContains(t, got, "[].email{string?}")
assertLineNotContains(t, got, "[].email{undefined}")
assertLineNotContains(t, got, "[].email{null}") assertLineNotContains(t, got, "[].email{null}")
t.Logf("output (%d lines):\n%s", len(got), strings.Join(got, "\n")) t.Logf("output (%d lines):\n%s", len(got), strings.Join(got, "\n"))

View File

@ -208,18 +208,43 @@ func (w *rawWalker) walkCollection(prefix string, values []any) {
w.emit(prefix + "{null}") w.emit(prefix + "{null}")
} }
// Emit each object shape. If a shape has exactly one instance and it // If multiple shapes share a common core (≥ 2 keys in > 50% of objects),
// looks like a map, walk it as a map instead of a struct. // treat them as one struct with optional fields.
for _, shape := range shapes { if len(shapes) > 1 {
if len(shape.instances) == 1 { var allObjs []map[string]any
obj := shape.instances[0] for _, s := range shapes {
isMap, _ := looksLikeMap(obj) allObjs = append(allObjs, s.instances...)
if isMap { }
w.walkMap(prefix, obj) if shouldMergeObjects(allObjs) {
continue // Merge all into one struct — absent fields become optional.
w.walkStruct(prefix, allObjs)
} else {
// No common core — emit each shape separately.
for _, shape := range shapes {
if len(shape.instances) == 1 {
obj := shape.instances[0]
isMap, _ := looksLikeMap(obj)
if isMap {
w.walkMap(prefix, obj)
continue
}
}
w.walkStruct(prefix, shape.instances)
} }
} }
w.walkStruct(prefix, shape.instances) } else {
// Single shape (or none) — emit directly.
for _, shape := range shapes {
if len(shape.instances) == 1 {
obj := shape.instances[0]
isMap, _ := looksLikeMap(obj)
if isMap {
w.walkMap(prefix, obj)
continue
}
}
w.walkStruct(prefix, shape.instances)
}
} }
// Emit primitive/array types (deduplicated). // Emit primitive/array types (deduplicated).
@ -344,3 +369,37 @@ func joinPath(prefix, field string) string {
} }
return prefix + "." + field return prefix + "." + field
} }
// shouldMergeObjects decides whether a pool of objects should be treated as
// one struct type with optional fields, rather than multiple distinct types.
//
// Heuristic: if ≥ 2 keys appear in more than half the objects, the objects
// likely share a common shape with optional fields. This catches the common
// case of API responses where every record has the same core fields but some
// records have extra ones (e.g. pagination results).
func shouldMergeObjects(objects []map[string]any) bool {
n := len(objects)
if n < 2 {
return false
}
// Count how many objects contain each key.
keyCount := make(map[string]int)
for _, obj := range objects {
for k := range obj {
keyCount[k]++
}
}
// Count keys that appear in a majority of objects.
threshold := n / 2
highFreq := 0
for _, count := range keyCount {
if count > threshold {
highFreq++
}
}
// Two or more high-frequency keys means the objects share a common core.
return highFreq >= 2
}

View File

@ -204,16 +204,40 @@ func (s *sampler) walkCollection(prefix string, values []any) {
s.emit(prefix + "{null}") s.emit(prefix + "{null}")
} }
for _, shape := range shapes { // If multiple shapes share a common core (≥ 2 keys in > 50% of objects),
if len(shape.instances) == 1 { // treat them as one struct with optional fields.
obj := shape.instances[0] if len(shapes) > 1 {
isMap, _ := looksLikeMap(obj) var allObjs []map[string]any
if isMap { for _, s := range shapes {
s.walkMap(prefix, obj) allObjs = append(allObjs, s.instances...)
continue }
if shouldMergeObjects(allObjs) {
s.walkStruct(prefix, allObjs)
} else {
for _, shape := range shapes {
if len(shape.instances) == 1 {
obj := shape.instances[0]
isMap, _ := looksLikeMap(obj)
if isMap {
s.walkMap(prefix, obj)
continue
}
}
s.walkStruct(prefix, shape.instances)
} }
} }
s.walkStruct(prefix, shape.instances) } else {
for _, shape := range shapes {
if len(shape.instances) == 1 {
obj := shape.instances[0]
isMap, _ := looksLikeMap(obj)
if isMap {
s.walkMap(prefix, obj)
continue
}
}
s.walkStruct(prefix, shape.instances)
}
} }
// Emit one sample per primitive type (not per value). // Emit one sample per primitive type (not per value).

View File

@ -1,14 +1,18 @@
[string]{Person} {Root}
[string].active{bool} .abc123{Abc}
[string].age{int} .abc123.active{bool}
[string].friends[]{Friend} .abc123.age{int}
[string].friends[].identification{Identification?} .abc123.friends[]{FriendsItem}
[string].friends[].identification.id{string?} .abc123.friends[].identification{Identification0?}
[string].friends[].identification.name{string} .abc123.friends[].identification.name{string}
[string].friends[].identification.number{string?} .abc123.friends[].identification.number{string}
[string].friends[].identification.restrictions{null} .abc123.friends[].identification.type{string}
[string].friends[].identification.restrictions[]{string} .abc123.friends[].name{string}
[string].friends[].identification.type{string} .abc123.name{string}
[string].friends[].name{string} .def456{Abc}
[string].name{string} .ghi789{Ghi}
[string].score{float?} .ghi789.active{bool}
.ghi789.age{int}
.ghi789.friends[]{FriendsItem}
.ghi789.name{string}
.ghi789.score{float}