golib/tools/jsontypes/sample.go

274 lines
6.2 KiB
Go

package jsontypes
import (
"encoding/json"
"fmt"
"strings"
)
// Sample extracts one representative JSON sample for each distinct type
// (by key set) from a decoded JSON value. The output is a flat list of
// lines in the same path format as RawPaths, but with actual JSON values
// at leaf nodes and compact JSON samples at type intro lines.
//
// The value must have been decoded with json.Decoder.UseNumber().
func Sample(v any) []string {
return SampleWithConfig(v, SampleConfig{})
}
// SampleConfig controls sample output.
type SampleConfig struct {
// MaxStringLen truncates string values. 0 = no truncation.
MaxStringLen int
}
// SampleWithConfig is like Sample but accepts configuration.
func SampleWithConfig(v any, cfg SampleConfig) []string {
s := &sampler{maxStr: cfg.MaxStringLen}
s.walk("", v)
return Coalesce(s.paths)
}
type sampler struct {
counter int
paths []string
maxStr int
}
func (s *sampler) emit(path string) {
s.paths = append(s.paths, path)
}
func (s *sampler) nextName(path string) string {
name := inferRawTypeName(path)
id := s.counter
s.counter++
return fmt.Sprintf("%s%d", name, id)
}
func (s *sampler) walk(prefix string, v any) {
switch val := v.(type) {
case nil:
s.emit(prefix + "{null}")
case bool:
s.emit(prefix + "{" + fmt.Sprintf("%v", val) + "}")
case json.Number:
s.emit(prefix + "{" + val.String() + "}")
case string:
s.emit(prefix + "{" + s.quotedString(val) + "}")
case map[string]any:
s.walkObject(prefix, val)
case []any:
s.walkArray(prefix, val)
}
}
func (s *sampler) quotedString(val string) string {
if s.maxStr > 0 && len(val) > s.maxStr {
return fmt.Sprintf("%q", val[:s.maxStr]) + "..."
}
return fmt.Sprintf("%q", val)
}
func (s *sampler) walkObject(prefix string, obj map[string]any) {
if len(obj) == 0 {
s.emit(prefix + "{empty}")
return
}
isMap, _ := looksLikeMap(obj)
if isMap {
s.walkMap(prefix, obj)
return
}
s.walkStruct(prefix, []map[string]any{obj})
}
func (s *sampler) walkMap(prefix string, obj map[string]any) {
keyName := inferKeyName(obj)
mapPrefix := prefix + "[" + keyName + "]"
values := make([]any, 0, len(obj))
for _, v := range obj {
values = append(values, v)
}
s.walkCollection(mapPrefix, values)
}
func (s *sampler) walkStruct(prefix string, instances []map[string]any) {
merged := mergeObjects(instances)
typeName := s.nextName(prefix)
// Emit type intro with a compact JSON sample of the first instance.
sample := s.compactSample(instances[0])
s.emit(prefix + "{" + typeName + ":" + sample + "}")
keys := sortedKeys(merged)
for _, k := range keys {
var fieldVals []any
for _, inst := range instances {
if v, ok := inst[k]; ok {
fieldVals = append(fieldVals, v)
} else {
fieldVals = append(fieldVals, absentValue)
}
}
s.walkCollection(joinPath(prefix, k), fieldVals)
}
}
// compactSample creates a one-line JSON of an object with truncated values.
func (s *sampler) compactSample(obj map[string]any) string {
keys := sortedKeys(obj)
var parts []string
for _, k := range keys {
parts = append(parts, fmt.Sprintf("%q:%s", k, s.compactValue(obj[k])))
}
return "{" + strings.Join(parts, ",") + "}"
}
func (s *sampler) compactValue(v any) string {
switch val := v.(type) {
case nil:
return "null"
case bool:
return fmt.Sprintf("%v", val)
case json.Number:
return val.String()
case string:
return s.quotedString(val)
case map[string]any:
return "{...}"
case []any:
if len(val) == 0 {
return "[]"
}
return "[...]"
default:
return "?"
}
}
func (s *sampler) walkArray(prefix string, arr []any) {
if len(arr) == 0 {
s.emit(prefix + "[]{empty}")
return
}
if isTupleCandidate(arr) {
for i, v := range arr {
s.walk(fmt.Sprintf("%s[%d]", prefix, i), v)
}
return
}
s.walkCollection(prefix+"[]", arr)
}
func (s *sampler) walkCollection(prefix string, values []any) {
type shapeInstances struct {
sig string
instances []map[string]any
}
var shapes []shapeInstances
shapeIndex := make(map[string]int)
hasNull := false
hasUndefined := false
var nonObjects []any
for _, v := range values {
if v == absentValue {
hasUndefined = true
continue
}
switch val := v.(type) {
case nil:
hasNull = true
case map[string]any:
sig := shapeSignature(val)
if idx, ok := shapeIndex[sig]; ok {
shapes[idx].instances = append(shapes[idx].instances, val)
} else {
shapeIndex[sig] = len(shapes)
shapes = append(shapes, shapeInstances{sig, []map[string]any{val}})
}
default:
nonObjects = append(nonObjects, val)
}
}
if hasUndefined {
s.emit(prefix + "{undefined}")
}
if hasNull {
s.emit(prefix + "{null}")
}
// If multiple shapes share a common core (≥ 2 keys in > 50% of objects),
// treat them as one struct with optional fields.
if len(shapes) > 1 {
var allObjs []map[string]any
for _, s := range shapes {
allObjs = append(allObjs, s.instances...)
}
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)
}
}
} 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).
seen := make(map[string]bool) // keyed by kind: "number", "string", "bool"
for _, v := range nonObjects {
switch val := v.(type) {
case json.Number:
if !seen["number"] {
seen["number"] = true
s.emit(prefix + "{" + val.String() + "}")
}
case string:
if !seen["string"] {
seen["string"] = true
s.emit(prefix + "{" + s.quotedString(val) + "}")
}
case bool:
if !seen["bool"] {
seen["bool"] = true
s.emit(prefix + "{" + fmt.Sprintf("%v", val) + "}")
}
case []any:
if !seen["array"] {
seen["array"] = true
s.walkArray(prefix, val)
}
}
}
if !hasNull && !hasUndefined && len(shapes) == 0 && len(nonObjects) == 0 {
s.emit(prefix + "{empty}")
}
}