diff --git a/.github/workflows/golang.yml b/.github/workflows/golang.yml new file mode 100644 index 0000000..32961fa --- /dev/null +++ b/.github/workflows/golang.yml @@ -0,0 +1,62 @@ +name: Go + +on: [push, pull_request] + +jobs: + + build: + runs-on: ubuntu-latest + steps: + - name: Install System Dependencies + run: | + apt-get update && apt-get install -y jq make + + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: '1.25.7' + cache-dependency-path: golang/go.sum + + - name: Check Formatting + run: | + if [ -n "$(gofmt -l .)" ]; then + echo "Go code is not formatted:" + gofmt -d . + exit 1 + fi + working-directory: ./golang + + - name: GolangCI-Lint + uses: golangci/golangci-lint-action@v9 + with: + version: latest + working-directory: ./golang + env: + GOFLAGS: "-buildvcs=false" + + - name: Run Gosec Security Scanner + uses: securego/gosec@master + with: + args: ./golang/... + env: + GOTOOLCHAIN: auto + + - name: Run govulncheck + run: | + go install golang.org/x/vuln/cmd/govulncheck@latest + govulncheck ./... + working-directory: ./golang + + - name: Build + run: make build + working-directory: ./golang + + - name: Test + run: go test -v -coverprofile=coverage.out ./... + working-directory: ./golang + + - name: Run Integration Test + run: bash ./test-dummy.sh diff --git a/.gitignore b/.gitignore index 57222fa..b42a09b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ node_modules/ coverage/ __pycache__ + +# Go artifacts +*.test +*.out diff --git a/README.md b/README.md index 3562054..b023fe1 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ Available also for **[PHP](./php/flatted.php)**. Available also for **[Python](./python/flatted.py)**. +Available also for **[Go](./golang/README.md)**. + - - - ## Announcement 📣 diff --git a/golang/.gitignore b/golang/.gitignore new file mode 100644 index 0000000..a5351f7 --- /dev/null +++ b/golang/.gitignore @@ -0,0 +1 @@ +/flatted \ No newline at end of file diff --git a/golang/Makefile b/golang/Makefile new file mode 100644 index 0000000..76b063c --- /dev/null +++ b/golang/Makefile @@ -0,0 +1,12 @@ +BINARY_NAME=flatted + +build: + go build -o $(BINARY_NAME) main.go + +test: + go test -v ./... + +clean: + rm -f $(BINARY_NAME) + +.PHONY: build test clean \ No newline at end of file diff --git a/golang/README.md b/golang/README.md new file mode 100644 index 0000000..1db041b --- /dev/null +++ b/golang/README.md @@ -0,0 +1,60 @@ +# flatted (Go) + +A super light and fast circular JSON parser. + +## Usage + +```go +package main + +import ( + "fmt" + "github.com/WebReflection/flatted/golang/pkg/flatted" +) + +type Group struct { + Name string `json:"name"` +} + +type User struct { + Name string `json:"name"` + Friend *User `json:"friend"` + Group *Group `json:"group"` +} + +func main() { + group := &Group{Name: "Developers"} + alice := &User{Name: "Alice", Group: group} + bob := &User{Name: "Bob", Group: group} + + alice.Friend = bob + bob.Friend = alice // Circular reference + + // Stringify Alice + s, _ := flatted.Stringify(alice) + fmt.Println(s) + // Output: [{"name":"Alice","friend":"1","group":"2"},{"name":"Bob","friend":"0","group":"2"},{"name":"Developers"}] + + // Flattening in action: + // Index "0" is Alice, Index "1" is Bob, Index "2" is the shared Group. + + // Parse back into a generic map structure + res, _ := flatted.Parse(s) + aliceMap := res.(map[string]any) + fmt.Println(aliceMap["name"]) // Alice +} +``` + +## CLI + +Build the binary using the provided Makefile: + +```bash +make build +``` + +Then use it to parse flatted JSON from stdin: + +```bash +echo '[{"a":"1"},"b"]' | ./flatted +``` diff --git a/golang/go.mod b/golang/go.mod new file mode 100644 index 0000000..6b315ea --- /dev/null +++ b/golang/go.mod @@ -0,0 +1,3 @@ +module github.com/WebReflection/flatted/golang + +go 1.25.7 diff --git a/golang/main.go b/golang/main.go new file mode 100644 index 0000000..47f4fc2 --- /dev/null +++ b/golang/main.go @@ -0,0 +1,89 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "os" + + "github.com/WebReflection/flatted/golang/pkg/flatted" +) + +// flatten reads standard JSON from r and writes flatted JSON to w. +func flatten(r io.Reader, w io.Writer) error { + input, err := io.ReadAll(r) + if err != nil { + return err + } + var data any + if err := json.Unmarshal(input, &data); err != nil { + return fmt.Errorf("invalid JSON input: %w", err) + } + s, err := flatted.Stringify(data, nil, nil) + if err != nil { + return err + } + fmt.Fprintln(w, s) + return nil +} + +// unflatten reads flatted JSON from r and writes standard JSON to w. +func unflatten(r io.Reader, w io.Writer) error { + input, err := io.ReadAll(r) + if err != nil { + return err + } + parsed, err := flatted.Parse(string(input), nil) + if err != nil { + return fmt.Errorf("invalid flatted input: %w", err) + } + output, err := json.MarshalIndent(parsed, "", " ") + if err != nil { + return err + } + fmt.Fprintln(w, string(output)) + return nil +} + +func main() { + var decompress bool + flag.BoolVar(&decompress, "d", false, "decompress (unflatten)") + flag.BoolVar(&decompress, "decompress", false, "decompress (unflatten)") + flag.BoolVar(&decompress, "unflatten", false, "decompress (unflatten)") + + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "Usage: %s [OPTION]... [FILE]\n", os.Args[0]) + fmt.Fprintln(os.Stderr, "Flatten or unflatten circular JSON structures.") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Options:") + flag.PrintDefaults() + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "If no FILE is provided, or if FILE is -, read from standard input.") + } + + flag.Parse() + + var r io.Reader = os.Stdin + if flag.NArg() > 0 && flag.Arg(0) != "-" { + f, err := os.Open(flag.Arg(0)) + if err != nil { + fmt.Fprintf(os.Stderr, "%s: %v\n", os.Args[0], err) + os.Exit(1) + } + defer f.Close() + r = f + } + + var err error + if decompress { + err = unflatten(r, os.Stdout) + } else { + err = flatten(r, os.Stdout) + } + + if err != nil { + fmt.Fprintf(os.Stderr, "%s: %v\n", os.Args[0], err) + os.Exit(1) + } +} diff --git a/golang/pkg/flatted/bench_test.go b/golang/pkg/flatted/bench_test.go new file mode 100644 index 0000000..e193b75 --- /dev/null +++ b/golang/pkg/flatted/bench_test.go @@ -0,0 +1,83 @@ +package flatted + +import ( + "encoding/json" + "os" + "testing" +) + +func BenchmarkFlatted(b *testing.B) { + // Create a circular structure for benchmarking + a := &[]any{map[string]any{}} + for i := 0; i < 10; i++ { + *a = append(*a, map[string]any{"id": i, "ref": a}) + } + (*a)[0].(map[string]any)["root"] = a + + s, _ := Stringify(a, nil, nil) + + b.Run("Stringify", func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _, err := Stringify(a, nil, nil) + if err != nil { + b.Fatal(err) + } + } + }) + + b.Run("Parse", func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _, _ = Parse(s, nil) + } + }) +} + +func BenchmarkCompare(b *testing.B) { + // Non-circular data for comparison + data := map[string]any{ + "name": "test", + "list": []any{1, 2, 3, 4, 5}, + "nested": map[string]any{ + "x": 1.0, + "y": 2.0, + }, + } + + b.Run("Flatted", func(b *testing.B) { + for i := 0; i < b.N; i++ { + _, _ = Stringify(data, nil, nil) + } + }) + + b.Run("JSON", func(b *testing.B) { + for i := 0; i < b.N; i++ { + _, _ = json.Marshal(data) + } + }) +} + +func BenchmarkLargeFile(b *testing.B) { + data, err := os.ReadFile("../test/65515.json") + if err != nil { + b.Skip("Skipping large file benchmark: file not found") + } + var raw map[string]any + _ = json.Unmarshal(data, &raw) + toolData := raw["toolData"] + strBytes, _ := json.Marshal(toolData) + str := string(strBytes) + obj, _ := Parse(str, nil) + + b.Run("Stringify", func(b *testing.B) { + for i := 0; i < b.N; i++ { + _, _ = Stringify(obj, nil, nil) + } + }) + b.Run("Parse", func(b *testing.B) { + for i := 0; i < b.N; i++ { + _, _ = Parse(str, nil) + } + }) +} diff --git a/golang/pkg/flatted/flatted.go b/golang/pkg/flatted/flatted.go new file mode 100644 index 0000000..8e3922c --- /dev/null +++ b/golang/pkg/flatted/flatted.go @@ -0,0 +1,277 @@ +package flatted + +import ( + "encoding/json" + "reflect" + "sort" + "strconv" + "strings" +) + +// flattedIndex is a internal type used to distinguish between +// actual strings and flatted indices during the reconstruction phase. +type flattedIndex string + +// Stringify converts a Go value into a specialized flatted JSON string. +func Stringify(value any, replacer any, space any) (string, error) { + knownKeys := []any{} + knownValues := []string{} + input := []any{} + + index := func(v any) string { + input = append(input, v) + idx := strconv.Itoa(len(input) - 1) + knownKeys = append(knownKeys, v) + knownValues = append(knownValues, idx) + return idx + } + + relate := func(v any) any { + if v == nil { + return nil + } + rv := reflect.ValueOf(v) + kind := rv.Kind() + if kind == reflect.String || kind == reflect.Slice || kind == reflect.Map || kind == reflect.Ptr { + for i, k := range knownKeys { + if kind == reflect.String { + if k == v { + return knownValues[i] + } + } else { + rk := reflect.ValueOf(k) + if rk.Kind() == kind && rk.Pointer() == rv.Pointer() { + return knownValues[i] + } + } + } + return index(v) + } + return v + } + + transform := func(v any) any { + rv := reflect.ValueOf(v) + if !rv.IsValid() { + return nil + } + if _, ok := v.(json.Marshaler); ok { + return v + } + // Dereference pointers to process the underlying Slice, Map, or Array + for rv.Kind() == reflect.Ptr && !rv.IsNil() { + rv = rv.Elem() + } + switch rv.Kind() { + case reflect.Slice, reflect.Array: + res := make([]any, rv.Len()) + for i := 0; i < rv.Len(); i++ { + res[i] = relate(rv.Index(i).Interface()) + } + return res + case reflect.Map: + res := make(map[string]any) + keys := rv.MapKeys() + sort.Slice(keys, func(i, j int) bool { + return keys[i].String() < keys[j].String() + }) + + whitelist, isWhitelist := replacer.([]string) + for _, key := range keys { + kStr := key.String() + if isWhitelist { + found := false + for _, w := range whitelist { + if w == kStr { + found = true + break + } + } + if !found { + continue + } + } + res[kStr] = relate(rv.MapIndex(key).Interface()) + } + return res + case reflect.Struct: + res := make(map[string]any) + t := rv.Type() + for i := 0; i < rv.NumField(); i++ { + field := t.Field(i) + if field.PkgPath != "" { + continue + } + name := field.Name + if tag := field.Tag.Get("json"); tag != "" { + name = strings.Split(tag, ",")[0] + } + res[name] = relate(rv.Field(i).Interface()) + } + return res + default: + return v + } + } + + index(value) + output := []any{} + for i := 0; i < len(input); i++ { + output = append(output, transform(input[i])) + } + + var b []byte + var err error + indent := "" + if s, ok := space.(string); ok { + indent = s + } else if i, ok := space.(int); ok { + indent = strings.Repeat(" ", i) + } + + if indent != "" { + b, err = json.MarshalIndent(output, "", indent) + } else { + b, err = json.Marshal(output) + } + + if err != nil { + return "", err + } + return string(b), nil +} + +// Parse converts a specialized flatted string into a Go value. +func Parse(text string, reviver func(key string, value any) any) (any, error) { + var jsonInput []any + if err := json.Unmarshal([]byte(text), &jsonInput); err != nil { + return nil, err + } + + var wrap func(any) any + wrap = func(v any) any { + if s, ok := v.(string); ok { + return flattedIndex(s) + } + if arr, ok := v.([]any); ok { + for i, item := range arr { + arr[i] = wrap(item) + } + return arr + } + if m, ok := v.(map[string]any); ok { + for k, item := range m { + m[k] = wrap(item) + } + return m + } + return v + } + + wrapped := make([]any, len(jsonInput)) + for i, v := range jsonInput { + wrapped[i] = wrap(v) + } + + input := make([]any, len(wrapped)) + for i, v := range wrapped { + if fi, ok := v.(flattedIndex); ok { + input[i] = string(fi) + } else { + input[i] = v + } + } + + if len(input) == 0 { + return nil, nil + } + + value := input[0] + rv := reflect.ValueOf(value) + if rv.IsValid() && (rv.Kind() == reflect.Slice || rv.Kind() == reflect.Map) { + set := make(map[uintptr]bool) + set[rv.Pointer()] = true + res := loop(value, input, set) + if reviver != nil { + return revive("", res, reviver), nil + } + return res, nil + } + + if reviver != nil { + return reviver("", value), nil + } + return value, nil +} + +func revive(key string, value any, reviver func(k string, v any) any) any { + if arr, ok := value.([]any); ok { + for i, v := range arr { + arr[i] = revive(strconv.Itoa(i), v, reviver) + } + } else if m, ok := value.(map[string]any); ok { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + m[k] = revive(k, m[k], reviver) + } + } + return reviver(key, value) +} + +func loop(value any, input []any, set map[uintptr]bool) any { + if arr, ok := value.([]any); ok { + for i, v := range arr { + if fi, ok := v.(flattedIndex); ok { + idx, _ := strconv.Atoi(string(fi)) + arr[i] = ref(input[idx], input, set) + } + } + return arr + } + if m, ok := value.(map[string]any); ok { + for k, v := range m { + if fi, ok := v.(flattedIndex); ok { + idx, _ := strconv.Atoi(string(fi)) + m[k] = ref(input[idx], input, set) + } + } + return m + } + return value +} + +func ref(value any, input []any, set map[uintptr]bool) any { + rv := reflect.ValueOf(value) + if rv.IsValid() && (rv.Kind() == reflect.Slice || rv.Kind() == reflect.Map) { + ptr := rv.Pointer() + if !set[ptr] { + set[ptr] = true + return loop(value, input, set) + } + } + return value +} + +// ToJSON converts a generic value into a JSON serializable object without losing recursion. +func ToJSON(value any) (any, error) { + s, err := Stringify(value, nil, nil) + if err != nil { + return nil, err + } + var res any + err = json.Unmarshal([]byte(s), &res) + return res, err +} + +// FromJSON converts a previously serialized object with recursion into a recursive one. +func FromJSON(value any) (any, error) { + b, err := json.Marshal(value) + if err != nil { + return nil, err + } + return Parse(string(b), nil) +} diff --git a/golang/pkg/flatted/flatted_test.go b/golang/pkg/flatted/flatted_test.go new file mode 100644 index 0000000..e633c6a --- /dev/null +++ b/golang/pkg/flatted/flatted_test.go @@ -0,0 +1,305 @@ +package flatted + +import ( + "encoding/json" + "os" + "reflect" + "regexp" + "testing" + "time" +) + +func TestFlatted(t *testing.T) { + t.Run("Primitives", func(t *testing.T) { + cases := []any{"a", 1.0, true, false, nil} + for _, c := range cases { + s, _ := Stringify(c, nil, nil) + p, _ := Parse(s, nil) + if p != c { + t.Errorf("Expected %v, got %v", c, p) + } + } + }) + + t.Run("Array", func(t *testing.T) { + a := []any{"a", 1.0, "b"} + s, _ := Stringify(a, nil, nil) + p, _ := Parse(s, nil) + if !reflect.DeepEqual(a, p) { + t.Errorf("Expected %v, got %v", a, p) + } + }) + + t.Run("Object", func(t *testing.T) { + o := map[string]any{"a": "a", "n": 1.0, "b": "b"} + s, _ := Stringify(o, nil, nil) + p, _ := Parse(s, nil) + if !reflect.DeepEqual(o, p) { + t.Errorf("Expected %v, got %v", o, p) + } + }) + + t.Run("ToFromJSON", func(t *testing.T) { + a := &[]any{map[string]any{}} + (*a)[0].(map[string]any)["a"] = a + + jsonObj, err := ToJSON(a) + if err != nil { + t.Fatal(err) + } + + back, err := FromJSON(jsonObj) + if err != nil { + t.Fatal(err) + } + + // Verify recursion is preserved + m := back.([]any)[0].(map[string]any) + // In Go, maps and slices are not comparable via == or !=. + // We check their pointers to verify that the circular reference is maintained. + if reflect.ValueOf(m["a"]).Pointer() != reflect.ValueOf(back).Pointer() { + t.Error("Recursion lost in ToJSON/FromJSON roundtrip") + } + }) +} + +func TestJSParity(t *testing.T) { + t.Run("MultipleNulls", func(t *testing.T) { + s, _ := Stringify([]any{nil, nil}, nil, nil) + if s != "[[null,null]]" { + t.Errorf("Expected [[null,null]], got %s", s) + } + }) + + t.Run("EmptyCollections", func(t *testing.T) { + s1, _ := Stringify([]any{}, nil, nil) + if s1 != "[[]]" { + t.Errorf("Expected [[]], got %s", s1) + } + s2, _ := Stringify(map[string]any{}, nil, nil) + if s2 != "[{}]" { + t.Errorf("Expected [{}], got %s", s2) + } + }) + + t.Run("RecursiveArray", func(t *testing.T) { + a := &[]any{} + *a = append(*a, a) + s, _ := Stringify(a, nil, nil) + if s != "[[\"0\"]]" { + t.Errorf("Expected [[\"0\"]], got %s", s) + } + p, _ := Parse(s, nil) + pa := p.([]any) + if reflect.ValueOf(pa).Pointer() != reflect.ValueOf(pa[0]).Pointer() { + t.Error("Recursive array reference lost") + } + }) + + t.Run("RecursiveObject", func(t *testing.T) { + o := map[string]any{} + o["o"] = o + s, _ := Stringify(o, nil, nil) + if s != "[{\"o\":\"0\"}]" { + t.Errorf("Expected [{\"o\":\"0\"}], got %s", s) + } + p, _ := Parse(s, nil) + po := p.(map[string]any) + if reflect.ValueOf(po).Pointer() != reflect.ValueOf(po["o"]).Pointer() { + t.Error("Recursive object reference lost") + } + }) + + t.Run("SpecialStrings", func(t *testing.T) { + special := "\\x7e" + o := map[string]any{"a": special} + s, _ := Stringify(o, nil, nil) + p, _ := Parse(s, nil) + if p.(map[string]any)["a"] != special { + t.Errorf("Expected %s, got %v", special, p.(map[string]any)["a"]) + } + }) + + t.Run("DateRevival", func(t *testing.T) { + d := time.Date(2023, 10, 27, 10, 0, 0, 0, time.UTC) + s, _ := Stringify(d, nil, nil) + + dateRegex := regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}`) + reviver := func(key string, value any) any { + if str, ok := value.(string); ok && dateRegex.MatchString(str) { + t, _ := time.Parse(time.RFC3339, str) + return t + } + return value + } + + p, _ := Parse(s, reviver) + if !p.(time.Time).Equal(d) { + t.Errorf("Expected %v, got %v", d, p) + } + }) + + t.Run("ReplacerWhitelist", func(t *testing.T) { + o := map[string]any{"a": 1.0, "b": 2.0} + s, _ := Stringify(o, []string{"b"}, nil) + if s != `[{"b":2}]` { + t.Errorf("Expected [{\"b\":2}], got %s", s) + } + }) + + t.Run("Indentation", func(t *testing.T) { + o := map[string]any{"a": 1.0} + s, _ := Stringify(o, nil, 2) + expected := "[\n {\n \"a\": 1\n }\n]" + if s != expected { + t.Errorf("Indentation mismatch.\nExpected:\n%s\nGot:\n%s", expected, s) + } + }) +} + +func TestComplexStructure(t *testing.T) { + unique := map[string]any{"a": "sup"} + nested := map[string]any{ + "prop": map[string]any{"value": 123.0}, + "a": []any{ + map[string]any{}, + map[string]any{ + "b": []any{ + map[string]any{ + "a": 1.0, + "d": 2.0, + "c": unique, + "z": map[string]any{ + "g": 2.0, + "a": unique, + "b": map[string]any{ + "r": 4.0, + "u": unique, + "c": 5.0, + }, + "f": 6.0, + }, + "h": 1.0, + }, + }, + }, + }, + "b": map[string]any{ + "e": "f", + "t": unique, + "p": 4.0, + }, + } + + s, _ := Stringify(nested, nil, nil) + p, _ := Parse(s, nil) + + // Verify structural integrity and shared references + res := p.(map[string]any) + resB := res["b"].(map[string]any) + resA := res["a"].([]any)[1].(map[string]any)["b"].([]any)[0].(map[string]any) + + if !reflect.DeepEqual(resB["t"], resA["c"]) { + t.Error("Shared object reference values differ") + } + + if reflect.ValueOf(resB["t"]).Pointer() != reflect.ValueOf(resA["c"]).Pointer() { + t.Error("Shared object reference identity lost") + } +} + +func TestEmptyKeys(t *testing.T) { + inner := map[string]any{"d": 1.0} + emptyKeyMap := map[string]any{"c": inner} + bMap := map[string]any{"": emptyKeyMap} + a := map[string]any{ + "b": bMap, + "_circular": emptyKeyMap, + } + + s, _ := Stringify(a, nil, nil) + p, _ := Parse(s, nil) + + res := p.(map[string]any) + if reflect.ValueOf(res["_circular"]).Pointer() != reflect.ValueOf(res["b"].(map[string]any)[""]).Pointer() { + t.Error("Circular reference via empty key lost") + } +} + +func TestCircularReference(t *testing.T) { + // Use a pointer to the slice to ensure a stable reference + // that survives append operations and matches JS reference behavior. + a := &[]any{map[string]any{}} + (*a)[0].(map[string]any)["a"] = a + *a = append(*a, a) + + s, err := Stringify(a, nil, nil) + if err != nil { + t.Fatal(err) + } + + if s != `[["1","0"],{"a":"0"}]` { + t.Errorf("Unexpected stringify output: %s", s) + } +} + +func TestLargeFiles(t *testing.T) { + files := []string{"65515.json", "65518.json"} + for _, fileName := range files { + t.Run(fileName, func(t *testing.T) { + // Path relative to the golang directory where tests are executed + data, err := os.ReadFile("../test/" + fileName) + if err != nil { + t.Skipf("Skipping %s: %v (file might not be present in all environments)", fileName, err) + return + } + + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("Failed to unmarshal %s: %v", fileName, err) + } + + toolData := raw["toolData"] + b, _ := json.Marshal(toolData) + res, err := Parse(string(b), nil) + if err != nil { + t.Errorf("Parse failed for %s: %v", fileName, err) + } + if res == nil { + t.Errorf("Parse returned nil for %s", fileName) + } + }) + } +} + +func TestConventionalComparison(t *testing.T) { + t.Run("CircularFailure", func(t *testing.T) { + type Item struct { + Self *Item + } + item := &Item{} + item.Self = item + + // Conventional serialization fails on circular references + _, err := json.Marshal(item) + if err == nil { + t.Error("Expected encoding/json to fail on circular reference") + } + + // Flatted handles it + _, err = Stringify(item, nil, nil) + if err != nil { + t.Errorf("Expected flatted to handle circular reference, got error: %v", err) + } + }) + + t.Run("OutputDifference", func(t *testing.T) { + data := map[string]string{"key": "value"} + std, _ := json.Marshal(data) + flat, _ := Stringify(data, nil, nil) + + if string(std) == flat { + t.Error("Flatted output should be distinct from standard JSON (it flattens into an array)") + } + }) +} diff --git a/test-dummy.sh b/test-dummy.sh new file mode 100755 index 0000000..5fe8d83 --- /dev/null +++ b/test-dummy.sh @@ -0,0 +1,53 @@ +#!/bin/bash + +# 1) Go to the golang directory and run a make build +echo "Building flatted binary..." +cd golang || exit 1 +make build || exit 1 +cd .. + +# 2) normalize sha256sum test/65518.json and verify the hash +ORIGINAL_HASH="c76a5329a11de440d28f8d8c4b37aafaa61bca9f1eb41a904b3d46312d5ab565" +ACTUAL_HASH=$(cat test/65518.json | jq --sort-keys -r . | sha256sum | awk '{ print $1 }') + +echo "Verifying checksum for test/65518.json..." +if [ "$ACTUAL_HASH" == "$ORIGINAL_HASH" ]; then + echo "Checksum verified successfully." +else + echo "Checksum mismatch!" + echo "Expected: $ORIGINAL_HASH" + echo "Actual: $ACTUAL_HASH" + exit 1 +fi + +# 3) Create a temp file, flatten the input, and verify the output hash +TEMP_FILE=$(mktemp) +trap 'rm -f "$TEMP_FILE"' EXIT + +echo "Flattening test/65518.json and verifying output..." +cat test/65518.json | ./golang/flatted | jq --sort-keys -r . > "$TEMP_FILE" + +EXPECTED_OUTPUT_HASH="feacd401744cea2e8597b41ddb3bad1fe6e77e306979529ddea9bc72e3f30a14" +ACTUAL_OUTPUT_HASH=$(sha256sum "$TEMP_FILE" | awk '{ print $1 }') + +if [ "$ACTUAL_OUTPUT_HASH" == "$EXPECTED_OUTPUT_HASH" ]; then + echo "Output checksum verified successfully." +else + echo "Output checksum mismatch!" + echo "Expected: $EXPECTED_OUTPUT_HASH" + echo "Actual: $ACTUAL_OUTPUT_HASH" + exit 1 +fi + +# 4) Unflatten the temp file and verify it matches the original normalized hash +echo "Unflattening and verifying round-trip integrity..." +ROUNDTRIP_HASH=$(cat "$TEMP_FILE" | ./golang/flatted -d | jq --sort-keys -r . | sha256sum | awk '{ print $1 }') + +if [ "$ROUNDTRIP_HASH" == "$ORIGINAL_HASH" ]; then + echo "Round-trip verification successful." +else + echo "Round-trip verification failed!" + echo "Expected: $ORIGINAL_HASH" + echo "Actual: $ROUNDTRIP_HASH" + exit 1 +fi \ No newline at end of file