mirror of
https://github.com/therootcompany/golib.git
synced 2026-11-04 06:55:00 +00:00
isBase64 matched common field names like "name" — too loose for map detection. Replace with isPronounceable which checks vowel/consonant rhythm: real field names have natural syllable structure (15-80% vowels, no 5+ consonant runs), while tokens and IDs don't. Also fix allLookLikeIDs to reject keys that don't match any ID pattern instead of silently accepting them.
585 lines
14 KiB
Go
585 lines
14 KiB
Go
package jsontypes
|
|
|
|
import (
|
|
"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. Uses hex detection, pronounceability, and field name checks.
|
|
func allLookLikeIDs(keys []string) bool {
|
|
for _, k := range keys {
|
|
if strings.ContainsAny(k, " \t\n") {
|
|
return false
|
|
}
|
|
// Pure hex is always an ID.
|
|
if len(k) >= 4 && isHex(k) {
|
|
continue
|
|
}
|
|
// Pronounceable strings are field names, not IDs.
|
|
if isPronounceable(k) {
|
|
return false
|
|
}
|
|
// Non-pronounceable alphanumeric of sufficient length → likely ID.
|
|
if len(k) >= 4 && isAlphanumeric(k) {
|
|
continue
|
|
}
|
|
// Doesn't match any ID pattern.
|
|
return false
|
|
}
|
|
return len(keys) > 0
|
|
}
|
|
|
|
func isAlphanumeric(s string) bool {
|
|
for _, r := range s {
|
|
if !unicode.IsLetter(r) && !unicode.IsDigit(r) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
|
|
|
|
// isPronounceable checks whether a string has the vowel/consonant rhythm of
|
|
// natural language. Field names like "metadata", "created_at", "userName" are
|
|
// pronounceable; tokens like "a1b2c3d4", "dGVzdA==", "xK9mP4q" are not.
|
|
//
|
|
// The check splits on underscores and case boundaries (camelCase), then
|
|
// verifies each word-like segment has a reasonable vowel ratio (15-80%) and
|
|
// no long consonant runs (>4). Pure-digit segments are ignored.
|
|
func isPronounceable(s string) bool {
|
|
// Split on underscores, then on camelCase boundaries.
|
|
segments := splitWordSegments(s)
|
|
if len(segments) == 0 {
|
|
return false
|
|
}
|
|
|
|
pronounceable := 0
|
|
total := 0
|
|
for _, seg := range segments {
|
|
// Skip pure digits and very short segments.
|
|
if len(seg) < 2 || isAllDigits(seg) {
|
|
continue
|
|
}
|
|
total++
|
|
if segmentIsPronounceable(seg) {
|
|
pronounceable++
|
|
}
|
|
}
|
|
|
|
if total == 0 {
|
|
return false
|
|
}
|
|
// Most segments should be pronounceable.
|
|
return pronounceable > total/2
|
|
}
|
|
|
|
func segmentIsPronounceable(s string) bool {
|
|
s = strings.ToLower(s)
|
|
vowels := 0
|
|
consonantRun := 0
|
|
maxConsonantRun := 0
|
|
letters := 0
|
|
|
|
for _, r := range s {
|
|
if !unicode.IsLetter(r) {
|
|
consonantRun = 0
|
|
continue
|
|
}
|
|
letters++
|
|
if isVowel(r) {
|
|
vowels++
|
|
consonantRun = 0
|
|
} else {
|
|
consonantRun++
|
|
if consonantRun > maxConsonantRun {
|
|
maxConsonantRun = consonantRun
|
|
}
|
|
}
|
|
}
|
|
|
|
if letters < 2 {
|
|
return false
|
|
}
|
|
|
|
ratio := float64(vowels) / float64(letters)
|
|
// English words typically have 30-50% vowels.
|
|
// Allow a wide band (15-80%) to cover abbreviations and acronyms.
|
|
if ratio < 0.15 || ratio > 0.80 {
|
|
return false
|
|
}
|
|
// No natural word has 5+ consonants in a row.
|
|
if maxConsonantRun > 4 {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func isVowel(r rune) bool {
|
|
switch r {
|
|
case 'a', 'e', 'i', 'o', 'u', 'y':
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isAllDigits(s string) bool {
|
|
for _, r := range s {
|
|
if !unicode.IsDigit(r) {
|
|
return false
|
|
}
|
|
}
|
|
return len(s) > 0
|
|
}
|
|
|
|
// splitWordSegments breaks a string into word-like segments by splitting on
|
|
// underscores, hyphens, and camelCase boundaries.
|
|
func splitWordSegments(s string) []string {
|
|
// First split on underscores and hyphens.
|
|
var parts []string
|
|
for _, part := range strings.FieldsFunc(s, func(r rune) bool {
|
|
return r == '_' || r == '-'
|
|
}) {
|
|
// Then split on camelCase boundaries.
|
|
parts = append(parts, splitCamelCase(part)...)
|
|
}
|
|
return parts
|
|
}
|
|
|
|
func splitCamelCase(s string) []string {
|
|
var segments []string
|
|
start := 0
|
|
runes := []rune(s)
|
|
for i := 1; i < len(runes); i++ {
|
|
if unicode.IsUpper(runes[i]) && (i > start) {
|
|
segments = append(segments, string(runes[start:i]))
|
|
start = i
|
|
}
|
|
}
|
|
segments = append(segments, string(runes[start:]))
|
|
return segments
|
|
}
|
|
|
|
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
|
|
}
|