mirror of
https://github.com/therootcompany/golib.git
synced 2026-11-04 15:05:00 +00:00
RawPaths walks JSON depth-first and emits flat paths with monotonically numbered type names (Root0, People2, etc.). Each type intro immediately precedes its fields, making the output streamable and order-stable. Also improves map detection heuristics: - All-numeric keys are always maps (even with 1-2 keys) - Keys containing digits detected as IDs (e.g., emp_101) - New isKnownFieldName with broad list of common field names (timestamps, flags, relations, etc.) as strong struct signal
476 lines
12 KiB
Go
476 lines
12 KiB
Go
package jsontypes
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"unicode"
|
|
)
|
|
|
|
// looksLikeMap uses heuristics to guess whether an object is a map (keyed
|
|
// collection) rather than a struct. Returns true/false and a confidence hint.
|
|
// If confidence is low, the caller should prompt the user.
|
|
func looksLikeMap(obj map[string]any) (isMap bool, confident bool) {
|
|
keys := sortedKeys(obj)
|
|
n := len(keys)
|
|
|
|
// All keys are integers → always a map, even with 1 key.
|
|
allInts := true
|
|
for _, k := range keys {
|
|
if _, err := strconv.ParseInt(k, 10, 64); err != nil {
|
|
allInts = false
|
|
break
|
|
}
|
|
}
|
|
if allInts && n > 0 {
|
|
return true, true
|
|
}
|
|
|
|
// If any key is a well-known struct field name, it's definitely a struct.
|
|
// This catches 1-2 key objects like {"name": ..., "age": ...}.
|
|
if hasKnownFieldName(keys) {
|
|
return false, true
|
|
}
|
|
|
|
// All keys contain mixed letters+digits → likely IDs (e.g., "emp_101").
|
|
if allContainDigits(keys) {
|
|
return true, true
|
|
}
|
|
|
|
// All keys same length and look like base64/hex IDs.
|
|
if allSameLength(keys) && allLookLikeIDs(keys) {
|
|
return true, true
|
|
}
|
|
|
|
if n < 3 {
|
|
// Too few keys and no strong signal either way.
|
|
return false, false
|
|
}
|
|
|
|
// Keys look like typical struct field names (camelCase, snake_case, short words).
|
|
// This must be checked before value-shape heuristics: a struct with many
|
|
// fields whose values happen to share a shape is still a struct.
|
|
if allLookLikeFieldNames(keys) {
|
|
return false, true
|
|
}
|
|
|
|
// Large number of keys where most values have the same shape → likely a map.
|
|
if n > 20 && valuesHaveSimilarShape(obj) {
|
|
return true, true
|
|
}
|
|
|
|
return false, false
|
|
}
|
|
|
|
// hasKnownFieldName returns true if any key matches a well-known struct field
|
|
// name. A single match is a strong signal — real maps don't have keys named
|
|
// "created_at" or "email".
|
|
func hasKnownFieldName(keys []string) bool {
|
|
for _, k := range keys {
|
|
if isKnownFieldName(k) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// isKnownFieldName checks whether a key is a common struct/object field name.
|
|
// This is deliberately broad — false positives (calling a map key a field)
|
|
// are less harmful than false negatives (treating a struct as a map).
|
|
func isKnownFieldName(k string) bool {
|
|
lower := strings.ToLower(k)
|
|
|
|
// Suffix patterns: timestamps, flags, relations
|
|
suffixes := []string{
|
|
"_at", "_on", "_by", "_id", "_ids", "_url", "_uri",
|
|
"_name", "_type", "_kind", "_key", "_code", "_date",
|
|
"_count", "_size", "_path", "_hash", "_token",
|
|
"_enabled", "_disabled", "_active", "_status",
|
|
}
|
|
for _, s := range suffixes {
|
|
if strings.HasSuffix(lower, s) {
|
|
return true
|
|
}
|
|
}
|
|
// camelCase suffixes
|
|
camelSuffixes := []string{
|
|
"At", "On", "By", "Id", "Ids", "Url", "Uri",
|
|
"Name", "Type", "Kind", "Key", "Code", "Date",
|
|
"Count", "Size", "Path", "Hash", "Token",
|
|
}
|
|
for _, s := range camelSuffixes {
|
|
if strings.HasSuffix(k, s) && len(k) > len(s) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// Exact matches: common field names across APIs
|
|
switch lower {
|
|
case
|
|
// identity
|
|
"id", "uid", "uuid", "guid", "slug",
|
|
// naming
|
|
"name", "title", "label", "description", "summary",
|
|
// typing/classification
|
|
"type", "kind", "category", "class", "role", "status", "state",
|
|
// content
|
|
"value", "data", "content", "body", "text", "message", "comment",
|
|
"url", "uri", "href", "link", "path",
|
|
"email", "phone", "address",
|
|
// flags
|
|
"active", "enabled", "disabled", "visible", "hidden",
|
|
"required", "optional", "readonly", "deleted", "archived",
|
|
"public", "private", "verified", "approved", "published",
|
|
// numbers
|
|
"count", "total", "size", "length", "width", "height",
|
|
"amount", "price", "cost", "quantity", "weight",
|
|
"score", "rating", "level", "priority", "order", "index", "position",
|
|
"version", "revision",
|
|
"min", "max", "limit", "offset", "page",
|
|
"latitude", "longitude", "lat", "lng", "lon",
|
|
// structure
|
|
"parent", "children", "items", "entries", "results", "records",
|
|
"tags", "labels", "groups", "members", "users", "roles",
|
|
"permissions", "scopes", "features", "options", "settings",
|
|
"config", "configuration", "preferences", "metadata", "meta",
|
|
"errors", "warnings",
|
|
// media
|
|
"format", "encoding", "charset", "locale", "language", "currency",
|
|
"color", "icon", "image", "avatar", "thumbnail", "logo",
|
|
"filename", "extension", "mimetype",
|
|
// auth
|
|
"username", "password", "token", "secret", "credential",
|
|
// time
|
|
"date", "time", "timestamp", "duration", "interval",
|
|
"start", "end", "expires":
|
|
return true
|
|
}
|
|
|
|
// Prefix patterns: is_*, has_*, can_*, should_*, allow_*
|
|
prefixes := []string{"is_", "has_", "can_", "should_", "allow_", "num_"}
|
|
for _, p := range prefixes {
|
|
if strings.HasPrefix(lower, p) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// allContainDigits checks if every key contains at least one digit,
|
|
// suggesting they are IDs rather than field names (e.g., "emp_101", "abc123").
|
|
func allContainDigits(keys []string) bool {
|
|
if len(keys) == 0 {
|
|
return false
|
|
}
|
|
for _, k := range keys {
|
|
hasDigit := false
|
|
for _, r := range k {
|
|
if unicode.IsDigit(r) {
|
|
hasDigit = true
|
|
break
|
|
}
|
|
}
|
|
if !hasDigit {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func allSameLength(keys []string) bool {
|
|
if len(keys) == 0 {
|
|
return true
|
|
}
|
|
l := len(keys[0])
|
|
for _, k := range keys[1:] {
|
|
if len(k) != l {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// allLookLikeIDs checks if keys look like identifiers/tokens rather than field
|
|
// names: no spaces, alphanumeric/base64/hex, and not common English field names.
|
|
func allLookLikeIDs(keys []string) bool {
|
|
for _, k := range keys {
|
|
if strings.ContainsAny(k, " \t\n") {
|
|
return false
|
|
}
|
|
// Hex or base64 strings of any length ≥ 4
|
|
if len(k) >= 4 && (isHex(k) || isAlphanumeric(k) || isBase64(k)) {
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
// Additional check: IDs typically don't look like field names.
|
|
// If ALL of them look like field names (e.g., camelCase), not IDs.
|
|
if allLookLikeFieldNames(keys) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func isAlphanumeric(s string) bool {
|
|
for _, r := range s {
|
|
if !unicode.IsLetter(r) && !unicode.IsDigit(r) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
|
|
|
|
func isBase64(s string) bool {
|
|
// Try standard and URL-safe base64
|
|
if _, err := base64.StdEncoding.DecodeString(s); err == nil {
|
|
return true
|
|
}
|
|
if _, err := base64.URLEncoding.DecodeString(s); err == nil {
|
|
return true
|
|
}
|
|
if _, err := base64.RawURLEncoding.DecodeString(s); err == nil {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isHex(s string) bool {
|
|
for _, r := range s {
|
|
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// allLookLikeFieldNames checks if keys look like typical struct field names:
|
|
// camelCase, snake_case, PascalCase, or short lowercase words.
|
|
func allLookLikeFieldNames(keys []string) bool {
|
|
fieldLike := 0
|
|
for _, k := range keys {
|
|
if looksLikeFieldName(k) {
|
|
fieldLike++
|
|
}
|
|
}
|
|
// If >80% look like field names, probably a struct
|
|
return fieldLike > len(keys)*4/5
|
|
}
|
|
|
|
func looksLikeFieldName(k string) bool {
|
|
if len(k) == 0 || len(k) > 40 {
|
|
return false
|
|
}
|
|
// Must start with a letter
|
|
runes := []rune(k)
|
|
if !unicode.IsLetter(runes[0]) {
|
|
return false
|
|
}
|
|
// Only letters, digits, underscores
|
|
for _, r := range runes {
|
|
if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '_' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// valuesHaveSimilarShape checks if most values in the object are objects with
|
|
// similar key sets.
|
|
func valuesHaveSimilarShape(obj map[string]any) bool {
|
|
shapes := make(map[string]int)
|
|
total := 0
|
|
for _, v := range obj {
|
|
if m, ok := v.(map[string]any); ok {
|
|
shapes[shapeSignature(m)]++
|
|
total++
|
|
}
|
|
}
|
|
if total == 0 {
|
|
return false
|
|
}
|
|
// Find most common shape
|
|
maxCount := 0
|
|
for _, count := range shapes {
|
|
if count > maxCount {
|
|
maxCount = count
|
|
}
|
|
}
|
|
return maxCount > total/2
|
|
}
|
|
|
|
// inferKeyName tries to infer a meaningful key name from the map's keys.
|
|
func inferKeyName(obj map[string]any) string {
|
|
keys := sortedKeys(obj)
|
|
if len(keys) == 0 {
|
|
return "string"
|
|
}
|
|
|
|
// All numeric?
|
|
allNum := true
|
|
for _, k := range keys {
|
|
if _, err := strconv.ParseInt(k, 10, 64); err != nil {
|
|
allNum = false
|
|
break
|
|
}
|
|
}
|
|
if allNum {
|
|
return "int"
|
|
}
|
|
|
|
// Check if all values are objects with a common field that matches the
|
|
// key (e.g., keys are "abc123" and objects have an "id" field with "abc123").
|
|
// This suggests the key name is "id".
|
|
for _, fieldName := range []string{"id", "ID", "Id", "_id"} {
|
|
match := true
|
|
for k, v := range obj {
|
|
if m, ok := v.(map[string]any); ok {
|
|
if val, exists := m[fieldName]; exists {
|
|
if fmt.Sprintf("%v", val) == k {
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
match = false
|
|
break
|
|
}
|
|
if match && len(obj) > 0 {
|
|
return fieldName
|
|
}
|
|
}
|
|
|
|
return "string"
|
|
}
|
|
|
|
// ambiguousTypeNames maps lowercase inferred names to their canonical form.
|
|
// When one of these is inferred, the parent type name is prepended and the
|
|
// canonical form is used (e.g., "json" in any casing → ParentJSON).
|
|
var ambiguousTypeNames = map[string]string{
|
|
"json": "JSON",
|
|
"data": "Data",
|
|
"item": "Item",
|
|
"value": "Value",
|
|
"result": "Result",
|
|
}
|
|
|
|
// inferTypeName tries to guess a struct name from the path context.
|
|
func inferTypeName(path string) string {
|
|
// Root path → "Root"
|
|
if path == "." {
|
|
return "Root"
|
|
}
|
|
|
|
// Root-level collection items (no parent type yet)
|
|
// e.g., ".[]", ".[string]", ".[int]"
|
|
if !strings.Contains(path, "{") {
|
|
name := inferTypeNameFromSegments(path)
|
|
if name == "" {
|
|
return "RootItem"
|
|
}
|
|
return name
|
|
}
|
|
|
|
return inferTypeNameFromSegments(path)
|
|
}
|
|
|
|
func inferTypeNameFromSegments(path string) string {
|
|
// Extract the last meaningful segment from the path
|
|
// e.g., ".friends[int]" → "Friend", ".{Person}.address" → "Address"
|
|
parts := strings.FieldsFunc(path, func(r rune) bool {
|
|
return r == '.' || r == '[' || r == ']' || r == '{' || r == '}'
|
|
})
|
|
if len(parts) == 0 {
|
|
return ""
|
|
}
|
|
last := parts[len(parts)-1]
|
|
// Skip index-like segments
|
|
if last == "int" || last == "string" || last == "id" {
|
|
if len(parts) >= 2 {
|
|
last = parts[len(parts)-2]
|
|
} else {
|
|
return ""
|
|
}
|
|
}
|
|
// Strip common suffixes like _id, _key, Id
|
|
last = strings.TrimSuffix(last, "_id")
|
|
last = strings.TrimSuffix(last, "_key")
|
|
last = strings.TrimSuffix(last, "Id")
|
|
last = strings.TrimSuffix(last, "Key")
|
|
if last == "" {
|
|
return ""
|
|
}
|
|
name := singularize(snakeToPascal(last))
|
|
|
|
// If the inferred name is too generic, use canonical form and prepend parent
|
|
if canonical, ok := ambiguousTypeNames[strings.ToLower(name)]; ok {
|
|
parent := parentTypeName(path)
|
|
if parent != "" {
|
|
return parent + canonical
|
|
}
|
|
return canonical
|
|
}
|
|
|
|
return name
|
|
}
|
|
|
|
// isUbiquitousField returns true if a field name is so common across all
|
|
// domains (databases, APIs, languages) that sharing it doesn't imply the
|
|
// objects are the same type. These are excluded when deciding whether to
|
|
// default to "same" or "different" types.
|
|
func isUbiquitousField(name string) bool {
|
|
// Exact matches
|
|
switch name {
|
|
case "id", "ID", "Id", "_id",
|
|
"name", "Name",
|
|
"type", "Type", "_type",
|
|
"kind", "Kind",
|
|
"slug", "Slug",
|
|
"label", "Label",
|
|
"title", "Title",
|
|
"description", "Description":
|
|
return true
|
|
}
|
|
// Suffix patterns: *_at, *_on, *At, *On (timestamps/dates)
|
|
if strings.HasSuffix(name, "_at") || strings.HasSuffix(name, "_on") ||
|
|
strings.HasSuffix(name, "At") || strings.HasSuffix(name, "On") {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// snakeToPascal converts snake_case or camelCase to PascalCase.
|
|
func snakeToPascal(s string) string {
|
|
parts := strings.Split(s, "_")
|
|
for i, p := range parts {
|
|
parts[i] = capitalize(p)
|
|
}
|
|
return strings.Join(parts, "")
|
|
}
|
|
|
|
func capitalize(s string) string {
|
|
if len(s) == 0 {
|
|
return s
|
|
}
|
|
return strings.ToUpper(s[:1]) + s[1:]
|
|
}
|
|
|
|
// singularize does a naive singularization for common English plurals.
|
|
func singularize(s string) string {
|
|
if strings.HasSuffix(s, "ies") && len(s) > 4 {
|
|
return s[:len(s)-3] + "y"
|
|
}
|
|
if strings.HasSuffix(s, "ses") || strings.HasSuffix(s, "xes") || strings.HasSuffix(s, "zes") {
|
|
return s[:len(s)-2]
|
|
}
|
|
if strings.HasSuffix(s, "ss") || strings.HasSuffix(s, "us") || strings.HasSuffix(s, "is") {
|
|
return s // not plural
|
|
}
|
|
if strings.HasSuffix(s, "s") && len(s) > 3 {
|
|
return s[:len(s)-1]
|
|
}
|
|
return s
|
|
}
|