mirror of
https://github.com/therootcompany/golib.git
synced 2026-11-04 06:55:00 +00:00
feat: merge object shapes with optional fields in raw paths
This commit is contained in:
parent
fabd193884
commit
fbd00ae4d2
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"))
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -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).
|
||||||
|
|||||||
32
tools/jsontypes/testdata/sample.paths
vendored
32
tools/jsontypes/testdata/sample.paths
vendored
@ -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}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user