diff --git a/go/go.mod b/go/go.mod new file mode 100644 index 0000000..250ed7d --- /dev/null +++ b/go/go.mod @@ -0,0 +1,3 @@ +module github.com/voxgig/util + +go 1.21 diff --git a/go/util.go b/go/util.go new file mode 100644 index 0000000..f96f5d7 --- /dev/null +++ b/go/util.go @@ -0,0 +1,480 @@ +/* Copyright © 2024-2025 Voxgig Ltd, MIT License. */ + +package util + +import ( + "encoding/json" + "math" + "sort" + "strings" + "unicode" +) + +// Camelify converts a kebab-case string (or slice of strings) to PascalCase. +// Example: "foo-bar" => "FooBar" +func Camelify(input string) string { + parts := strings.Split(input, "-") + return camelifyParts(parts) +} + +// CamelifySlice converts a slice of strings to PascalCase. +func CamelifySlice(input []string) string { + parts := make([]string, len(input)) + for i, s := range input { + parts[i] = s + } + return camelifyParts(parts) +} + +func camelifyParts(parts []string) string { + var sb strings.Builder + for _, p := range parts { + if p == "" { + continue + } + runes := []rune(p) + runes[0] = unicode.ToUpper(runes[0]) + sb.WriteString(string(runes)) + } + return sb.String() +} + +// DiveEntry represents a single entry returned by Dive: a path and its value. +type DiveEntry struct { + Path []string + Value any +} + +// Dive traverses a nested map to the specified depth (default 2), +// returning a slice of DiveEntry with [path, value] pairs. +func Dive(node map[string]any, depth ...int) []DiveEntry { + d := 2 + if len(depth) > 0 { + d = depth[0] + } + var items []DiveEntry + diveInternal(node, d, nil, &items) + return items +} + +func hasOwnKeys(m map[string]any) bool { + for range m { + return true + } + return false +} + +func diveInternal(node map[string]any, d int, prefix []string, items *[]DiveEntry) { + if node == nil { + return + } + for key, child := range node { + if key == "$" { + pathCopy := make([]string, len(prefix)) + copy(pathCopy, prefix) + *items = append(*items, DiveEntry{Path: pathCopy, Value: child}) + } else if childMap, ok := child.(map[string]any); ok && d > 1 && hasOwnKeys(childMap) { + newPrefix := append(append([]string{}, prefix...), key) + diveInternal(childMap, d-1, newPrefix, items) + } else { + newPath := append(append([]string{}, prefix...), key) + *items = append(*items, DiveEntry{Path: newPath, Value: child}) + } + } +} + +// Get retrieves a deeply nested value from a map using a dot-separated path. +// Example: Get(map, "a.b") returns map["a"]["b"] +func Get(root any, path string) any { + return GetPath(root, strings.Split(path, ".")) +} + +// GetPath retrieves a deeply nested value from a map using a path slice. +func GetPath(root any, path []string) any { + node := root + for _, key := range path { + if node == nil { + return nil + } + switch m := node.(type) { + case map[string]any: + node = m[key] + default: + return nil + } + } + return node +} + +// Joins joins array elements with hierarchical separators. +// Example: Joins(["a","1","b","2","c","3","d","4"], ":", ",", "/") +// +// => "a:1,b:2/c:3,d:4" +func Joins(arr []any, seps ...string) string { + if len(arr) == 0 { + return "" + } + var sb strings.Builder + for i, v := range arr { + sb.WriteString(toString(v)) + if i < len(arr)-1 { + for j := len(seps) - 1; j >= 0; j-- { + if (i+1)%(1< 0 { + digits = append([]byte{byte('0' + n%10)}, digits...) + n /= 10 + } + return string(digits) +} + +// Pinify converts a path slice to pin notation with alternating : and , separators. +// Example: ["a","b","c","d"] => "a:b,c:d" +func Pinify(path []string) string { + var sb strings.Builder + for i, p := range path { + sb.WriteString(p) + if i < len(path)-1 { + if i%2 == 0 { + sb.WriteString(":") + } else { + sb.WriteString(",") + } + } + } + return sb.String() +} + +// OrderItem represents an item in an ordered collection. +type OrderItem struct { + Key string + Title string + Fields map[string]any +} + +// OrderSpec defines how items should be ordered. +type OrderSpec struct { + Sort string + Exclude string + Include string +} + +// Order orders and filters a map of items according to the spec. +func Order(itemMap map[string]map[string]any, spec *OrderSpec) []map[string]any { + items := make([]map[string]any, 0, len(itemMap)) + // Maintain insertion order by sorting keys (Go maps are unordered) + keys := make([]string, 0, len(itemMap)) + for k := range itemMap { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, k := range keys { + item := copyMap(itemMap[k]) + if _, ok := item["key"]; !ok { + item["key"] = k + } + items = append(items, item) + } + + if spec == nil { + return items + } + + items = orderSort(items, itemMap, spec) + items = orderExclude(items, spec) + items = orderInclude(items, spec) + + return items +} + +func copyMap(m map[string]any) map[string]any { + result := make(map[string]any, len(m)) + for k, v := range m { + result[k] = v + } + return result +} + +func orderSort(items []map[string]any, itemMap map[string]map[string]any, spec *OrderSpec) []map[string]any { + if spec.Sort == "" { + return items + } + + keyOrder := splitTrim(spec.Sort) + + keyOrderSet := make(map[string]bool) + for _, k := range keyOrder { + if k != "human$" && k != "alpha$" { + keyOrderSet[k] = true + } + } + + var finalKeys []string + for _, k := range keyOrder { + switch k { + case "alpha$": + filtered := filterItems(items, keyOrderSet) + sort.Slice(filtered, func(i, j int) bool { + ti := getTitle(filtered[i]) + tj := getTitle(filtered[j]) + return ti < tj + }) + for _, item := range filtered { + finalKeys = append(finalKeys, item["key"].(string)) + } + case "human$": + filtered := filterItems(items, keyOrderSet) + maxLen := 0 + for _, item := range filtered { + t := getTitle(item) + if len(t) > maxLen { + maxLen = len(t) + } + } + padLen := maxLen + 1 + for _, item := range filtered { + t := getTitle(item) + padded := padStart(t, padLen, '0') + item["title$"] = padded + } + sort.Slice(filtered, func(i, j int) bool { + return filtered[i]["title$"].(string) < filtered[j]["title$"].(string) + }) + for _, item := range filtered { + finalKeys = append(finalKeys, item["key"].(string)) + } + default: + finalKeys = append(finalKeys, k) + } + } + + result := make([]map[string]any, 0, len(finalKeys)) + for _, k := range finalKeys { + if orig, ok := itemMap[k]; ok { + item := copyMap(orig) + if _, ok := item["key"]; !ok { + item["key"] = k + } + // Copy title$ if present in items + for _, it := range items { + if it["key"] == k { + if ts, ok := it["title$"]; ok { + item["title$"] = ts + } + break + } + } + result = append(result, item) + } + } + + return result +} + +func filterItems(items []map[string]any, excludeSet map[string]bool) []map[string]any { + var result []map[string]any + for _, item := range items { + if key, ok := item["key"].(string); ok && !excludeSet[key] { + result = append(result, item) + } + } + return result +} + +func getTitle(item map[string]any) string { + if t, ok := item["title"].(string); ok { + return t + } + return "" +} + +func padStart(s string, length int, pad byte) string { + if len(s) >= length { + return s + } + return strings.Repeat(string(pad), length-len(s)) + s +} + +func orderExclude(items []map[string]any, spec *OrderSpec) []map[string]any { + if spec.Exclude == "" { + return items + } + excludes := splitTrim(spec.Exclude) + excludeSet := make(map[string]bool) + for _, e := range excludes { + excludeSet[e] = true + } + var result []map[string]any + for _, item := range items { + if key, ok := item["key"].(string); ok && !excludeSet[key] { + result = append(result, item) + } + } + return result +} + +func orderInclude(items []map[string]any, spec *OrderSpec) []map[string]any { + if spec.Include == "" { + return items + } + includes := splitTrim(spec.Include) + includeSet := make(map[string]bool) + for _, inc := range includes { + includeSet[inc] = true + } + var result []map[string]any + for _, item := range items { + if key, ok := item["key"].(string); ok && includeSet[key] { + result = append(result, item) + } + } + return result +} + +func splitTrim(s string) []string { + parts := strings.Split(s, ",") + result := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + result = append(result, p) + } + } + return result +} + +// Entity processes a model to extract entity field validation. +func Entity(model map[string]any) map[string]any { + main, _ := model["main"].(map[string]any) + if main == nil { + return nil + } + ent, _ := main["ent"].(map[string]any) + if ent == nil { + return nil + } + + entries := Dive(ent) + entMap := make(map[string]any) + + for _, entry := range entries { + path := entry.Path + entVal, ok := entry.Value.(map[string]any) + if !ok { + continue + } + + valid := make(map[string]any) + if v, ok := entVal["valid"].(map[string]any); ok { + for k, val := range v { + valid[k] = val + } + } + + fieldMap, ok := entVal["field"].(map[string]any) + if !ok { + continue + } + + for name, fieldVal := range fieldMap { + field, ok := fieldVal.(map[string]any) + if !ok { + continue + } + + fv := field["kind"] + if fieldValid, ok := field["valid"]; ok { + switch v := fieldValid.(type) { + case string: + fv = toString(fv) + "." + v + default: + fv = v + } + } + valid[name] = fv + } + + key := path[0] + "/" + path[1] + entMap[key] = map[string]any{ + "valid_json": valid, + } + } + + return entMap +} + +// Stringify converts a value to a JSON string. +func Stringify(val any) string { + b, err := json.Marshal(val) + if err != nil { + return "" + } + return string(b) +} + +// Decircular returns a deep copy of the value. In Go, true circular references +// in map/slice structures are uncommon, but this provides a deep-copy utility +// consistent with the TypeScript version. +func Decircular(val any) any { + return decircularInternal(val) +} + +func decircularInternal(val any) any { + if val == nil { + return nil + } + switch v := val.(type) { + case map[string]any: + result := make(map[string]any, len(v)) + for key, value := range v { + result[key] = decircularInternal(value) + } + return result + case []any: + result := make([]any, len(v)) + for i, value := range v { + result[i] = decircularInternal(value) + } + return result + default: + return val + } +} diff --git a/go/util_test.go b/go/util_test.go new file mode 100644 index 0000000..8ef6219 --- /dev/null +++ b/go/util_test.go @@ -0,0 +1,307 @@ +/* Copyright © 2024-2025 Voxgig Ltd, MIT License. */ + +package util + +import ( + "encoding/json" + "reflect" + "testing" +) + +func TestHappy(t *testing.T) { + // Verify functions exist by calling them with minimal args + _ = Camelify("") + _ = Dive(nil) + _ = Get(nil, "") + _ = Joins(nil) + _ = Pinify(nil) + _ = Entity(nil) +} + +func TestCamelify(t *testing.T) { + result := Camelify("foo-bar") + if result != "FooBar" { + t.Errorf("Camelify('foo-bar') = %q, want %q", result, "FooBar") + } +} + +func TestCamelifySlice(t *testing.T) { + result := CamelifySlice([]string{"foo", "bar"}) + if result != "FooBar" { + t.Errorf("CamelifySlice(['foo','bar']) = %q, want %q", result, "FooBar") + } +} + +func TestDive(t *testing.T) { + input := map[string]any{ + "color": map[string]any{ + "red": map[string]any{"x": 1}, + "green": map[string]any{"x": 2}, + }, + "planet": map[string]any{ + "mercury": map[string]any{"y": map[string]any{"z": 3}}, + "venus": map[string]any{"y": map[string]any{"z": 4}}, + }, + } + + result := Dive(input) + + // Since Go maps are unordered, check by building a lookup + if len(result) != 4 { + t.Fatalf("Dive returned %d entries, want 4", len(result)) + } + + lookup := make(map[string]any) + for _, entry := range result { + key := entry.Path[0] + "." + entry.Path[1] + lookup[key] = entry.Value + } + + assertMapValue(t, lookup, "color.red", map[string]any{"x": 1}) + assertMapValue(t, lookup, "color.green", map[string]any{"x": 2}) + assertMapValue(t, lookup, "planet.mercury", map[string]any{"y": map[string]any{"z": 3}}) + assertMapValue(t, lookup, "planet.venus", map[string]any{"y": map[string]any{"z": 4}}) +} + +func TestGet(t *testing.T) { + root := map[string]any{ + "a": map[string]any{ + "b": 1, + }, + } + result := Get(root, "a.b") + if result != 1 { + t.Errorf("Get(root, 'a.b') = %v, want 1", result) + } +} + +func TestGetNil(t *testing.T) { + result := Get(nil, "a.b") + if result != nil { + t.Errorf("Get(nil, 'a.b') = %v, want nil", result) + } +} + +func TestJoins(t *testing.T) { + arr := []any{"a", 1, "b", 2, "c", 3, "d", 4, "e", 5, "f", 6} + result := Joins(arr, ":", ",", "/") + expected := "a:1,b:2/c:3,d:4/e:5,f:6" + if result != expected { + t.Errorf("Joins() = %q, want %q", result, expected) + } +} + +func TestPinify(t *testing.T) { + result := Pinify([]string{"a", "b", "c", "d"}) + if result != "a:b,c:d" { + t.Errorf("Pinify(['a','b','c','d']) = %q, want %q", result, "a:b,c:d") + } +} + +func TestEntity(t *testing.T) { + model := map[string]any{ + "main": map[string]any{ + "ent": map[string]any{ + "qaz": map[string]any{ + "zed": map[string]any{ + "valid": map[string]any{ + "$$": "Open", + }, + "field": map[string]any{ + "foo": map[string]any{ + "valid": map[string]any{ + "a": "Number", + }, + }, + }, + }, + }, + }, + }, + } + + result := Entity(model) + + expected := map[string]any{ + "qaz/zed": map[string]any{ + "valid_json": map[string]any{ + "$$": "Open", + "foo": map[string]any{"a": "Number"}, + }, + }, + } + + if !reflect.DeepEqual(result, expected) { + t.Errorf("Entity() = %v, want %v", toJSON(result), toJSON(expected)) + } +} + +func TestStringify(t *testing.T) { + result := Stringify(map[string]any{"a": 1, "b": "hello"}) + // JSON key order may vary in Go, parse and compare + var parsed map[string]any + json.Unmarshal([]byte(result), &parsed) + + if parsed["a"] != float64(1) || parsed["b"] != "hello" { + t.Errorf("Stringify({a:1,b:'hello'}) = %q", result) + } + + if Stringify(nil) != "null" { + t.Errorf("Stringify(nil) = %q, want 'null'", Stringify(nil)) + } + + if Stringify(42) != "42" { + t.Errorf("Stringify(42) = %q, want '42'", Stringify(42)) + } +} + +func TestDecircular(t *testing.T) { + // Simple non-circular object passes through + input := map[string]any{"a": 1, "b": map[string]any{"c": 2}} + result := Decircular(input) + expected := map[string]any{"a": 1, "b": map[string]any{"c": 2}} + if !reflect.DeepEqual(result, expected) { + t.Errorf("Decircular simple = %v, want %v", result, expected) + } + + // Handles nil + if Decircular(nil) != nil { + t.Errorf("Decircular(nil) = %v, want nil", Decircular(nil)) + } + + // Handles primitives + if Decircular(42) != 42 { + t.Errorf("Decircular(42) = %v, want 42", Decircular(42)) + } + if Decircular("hello") != "hello" { + t.Errorf("Decircular('hello') = %v, want 'hello'", Decircular("hello")) + } + + // Handles arrays + arrInput := []any{1, 2, map[string]any{"a": 3}} + arrResult := Decircular(arrInput) + arrExpected := []any{1, 2, map[string]any{"a": 3}} + if !reflect.DeepEqual(arrResult, arrExpected) { + t.Errorf("Decircular array = %v, want %v", arrResult, arrExpected) + } + + // Deeply nested non-circular object + deep := map[string]any{ + "a": map[string]any{ + "b": map[string]any{ + "c": map[string]any{ + "d": map[string]any{ + "e": 5, + }, + }, + }, + }, + } + deepResult := Decircular(deep) + if !reflect.DeepEqual(deepResult, deep) { + t.Errorf("Decircular deep = %v, want %v", deepResult, deep) + } +} + +func TestOrder(t *testing.T) { + // Empty + result := Order(map[string]map[string]any{}, nil) + if len(result) != 0 { + t.Errorf("Order({}, nil) returned %d items, want 0", len(result)) + } + + items := map[string]map[string]any{ + "code": {"title": "Coding"}, + "tech": {"title": "Technology"}, + "devr": {"title": "Developer Relations"}, + } + + // No spec + result = Order(items, nil) + assertOrderKeys(t, result, []string{"code", "devr", "tech"}) + + // Exclude + result = Order(items, &OrderSpec{Exclude: "code,tech"}) + assertOrderKeys(t, result, []string{"devr"}) + + // Include + result = Order(items, &OrderSpec{Include: "code,tech"}) + assertOrderKeys(t, result, []string{"code", "tech"}) + + // Exclude wins over include + result = Order(items, &OrderSpec{Exclude: "code", Include: "code,tech"}) + assertOrderKeys(t, result, []string{"tech"}) + + // Alpha sort + result = Order(items, &OrderSpec{Sort: "alpha$"}) + assertOrderKeys(t, result, []string{"code", "devr", "tech"}) + assertOrderTitles(t, result, []string{"Coding", "Developer Relations", "Technology"}) + + // Explicit sort + result = Order(items, &OrderSpec{Sort: "tech,code"}) + assertOrderKeys(t, result, []string{"tech", "code"}) + + // Mixed sort with alpha$ + result = Order(items, &OrderSpec{Sort: "tech,alpha$"}) + assertOrderKeys(t, result, []string{"tech", "code", "devr"}) +} + +func TestOrderHumanSort(t *testing.T) { + nums := map[string]map[string]any{ + "1": {"title": "1"}, + "10": {"title": "10"}, + "2": {"title": "2"}, + "tech": {"title": "Technology"}, + } + + // Alpha sort + result := Order(nums, &OrderSpec{Sort: "alpha$"}) + assertOrderKeys(t, result, []string{"1", "10", "2", "tech"}) + + // Human sort + result = Order(nums, &OrderSpec{Sort: "human$"}) + assertOrderKeys(t, result, []string{"1", "2", "10", "tech"}) +} + +// Helpers + +func assertMapValue(t *testing.T, m map[string]any, key string, expected any) { + t.Helper() + val, ok := m[key] + if !ok { + t.Errorf("key %q not found in map", key) + return + } + if !reflect.DeepEqual(val, expected) { + t.Errorf("m[%q] = %v, want %v", key, val, expected) + } +} + +func assertOrderKeys(t *testing.T, items []map[string]any, expectedKeys []string) { + t.Helper() + if len(items) != len(expectedKeys) { + t.Errorf("got %d items, want %d", len(items), len(expectedKeys)) + return + } + for i, item := range items { + key, _ := item["key"].(string) + if key != expectedKeys[i] { + t.Errorf("item[%d].key = %q, want %q", i, key, expectedKeys[i]) + } + } +} + +func assertOrderTitles(t *testing.T, items []map[string]any, expectedTitles []string) { + t.Helper() + for i, item := range items { + title, _ := item["title"].(string) + if title != expectedTitles[i] { + t.Errorf("item[%d].title = %q, want %q", i, title, expectedTitles[i]) + } + } +} + +func toJSON(v any) string { + b, _ := json.MarshalIndent(v, "", " ") + return string(b) +}